From dcbdfcc288ad38468f0eb29ab20e3055e27f1092 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sat, 29 Nov 2025 06:17:07 +0100 Subject: [PATCH] feat: Add client custom fields, link templates, UI feature flags, and client billing support Add client custom fields (JSON) for flexible data storage Implement link templates system for dynamic URL generation from custom fields Add client_id support to time entries for direct client billing (project_id now nullable) Implement user-level UI feature flags for customizable navigation visibility Add system-wide UI feature flags in settings for admin control Fix metadata column naming (user_badges.achievement_metadata, leaderboard_entries.entry_metadata) Update templates and routes to support new features Add comprehensive UI feature flag management in admin and user settings Enhance client views with custom fields and link template integration Update time entry forms to support client billing Add tests for system UI flags Migrations: 075-080 for custom fields, link templates, UI flags, client billing, and metadata fixes --- app/__init__.py | 2 + app/models/__init__.py | 2 + app/models/client.py | 50 ++++ app/models/link_template.py | 62 ++++ app/models/reporting.py | 4 + app/models/settings.py | 74 ++++- app/models/time_entry.py | 41 ++- app/models/user.py | 35 +++ app/repositories/time_entry_repository.py | 52 +++- app/routes/admin.py | 38 +++ app/routes/api.py | 37 +-- app/routes/api_v1.py | 8 +- app/routes/clients.py | 32 +++ app/routes/link_templates.py | 145 ++++++++++ app/routes/main.py | 5 +- app/routes/reports.py | 265 ++++++++++++++++++ app/routes/scheduled_reports.py | 203 +++++++++++++- app/routes/team_chat.py | 201 ++++++++++++- app/routes/timer.py | 261 ++++++++++------- app/routes/user.py | 33 +++ app/schemas/client_schema.py | 3 + app/schemas/time_entry_schema.py | 34 ++- app/services/client_service.py | 2 + app/services/time_tracking_service.py | 93 +++++- app/templates/admin/link_templates/form.html | 78 ++++++ app/templates/admin/link_templates/list.html | 107 +++++++ app/templates/admin/settings.html | 160 +++++++++++ app/templates/approvals/list.html | 4 + app/templates/approvals/view.html | 7 + app/templates/base.html | 38 +++ app/templates/chat/channel.html | 218 +++++++++++++- app/templates/client_portal/time_entries.html | 10 +- app/templates/clients/create.html | 29 ++ app/templates/clients/edit.html | 40 +++ app/templates/clients/view.html | 45 +++ app/templates/expenses/list.html | 30 +- app/templates/invoices/list.html | 30 +- app/templates/main/dashboard.html | 111 ++++++-- app/templates/main/search.html | 8 +- app/templates/mileage/list.html | 30 +- app/templates/payments/list.html | 30 +- app/templates/per_diem/list.html | 30 +- app/templates/reports/index.html | 221 ++++++++++++++- app/templates/reports/summary.html | 4 + app/templates/reports/task_report.html | 8 + app/templates/reports/user_report.html | 8 + app/templates/timer/manual_entry.html | 65 ++++- app/templates/timer/timer_page.html | 121 +++++--- app/templates/user/settings.html | 248 ++++++++++++++++ app/templates/weekly_goals/view.html | 8 +- ...client_custom_fields_and_link_templates.py | 77 +++++ .../076_add_client_billing_to_time_entries.py | 140 +++++++++ .../versions/077_add_ui_feature_flags.py | 182 ++++++++++++ .../078_add_system_ui_feature_flags.py | 136 +++++++++ .../079_rename_user_badges_metadata_column.py | 110 ++++++++ .../versions/080_fix_metadata_column_names.py | 126 +++++++++ tests/test_system_ui_flags.py | 31 ++ tests/test_user_settings.py | 132 +++++++++ 58 files changed, 3935 insertions(+), 369 deletions(-) create mode 100644 app/models/link_template.py create mode 100644 app/routes/link_templates.py create mode 100644 app/templates/admin/link_templates/form.html create mode 100644 app/templates/admin/link_templates/list.html create mode 100644 migrations/versions/075_add_client_custom_fields_and_link_templates.py create mode 100644 migrations/versions/076_add_client_billing_to_time_entries.py create mode 100644 migrations/versions/077_add_ui_feature_flags.py create mode 100644 migrations/versions/078_add_system_ui_feature_flags.py create mode 100644 migrations/versions/079_rename_user_badges_metadata_column.py create mode 100644 migrations/versions/080_fix_metadata_column_names.py create mode 100644 tests/test_system_ui_flags.py diff --git a/app/__init__.py b/app/__init__.py index b689929..28c6fae 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -932,6 +932,7 @@ def create_app(config=None): from app.routes.deals import deals_bp from app.routes.leads import leads_bp from app.routes.kiosk import kiosk_bp + from app.routes.link_templates import link_templates_bp try: from app.routes.audit_logs import audit_logs_bp @@ -983,6 +984,7 @@ def create_app(config=None): app.register_blueprint(contacts_bp) app.register_blueprint(deals_bp) app.register_blueprint(leads_bp) + app.register_blueprint(link_templates_bp) # audit_logs_bp is registered above with error handling # Register integration connectors diff --git a/app/models/__init__.py b/app/models/__init__.py index 2d332b3..b9d8734 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -72,6 +72,7 @@ from .client_time_approval import ClientTimeApproval, ClientApprovalPolicy, Clie from .custom_report import CustomReportConfig from .gamification import Badge, UserBadge, Leaderboard, LeaderboardEntry from .expense_gps import MileageTrack +from .link_template import LinkTemplate __all__ = [ "User", @@ -170,4 +171,5 @@ __all__ = [ "Leaderboard", "LeaderboardEntry", "MileageTrack", + "LinkTemplate", ] diff --git a/app/models/client.py b/app/models/client.py index bc96bbf..5d8db33 100644 --- a/app/models/client.py +++ b/app/models/client.py @@ -4,6 +4,7 @@ from werkzeug.security import generate_password_hash, check_password_hash from app import db from .client_prepaid_consumption import ClientPrepaidConsumption import secrets +import json class Client(db.Model): @@ -32,8 +33,12 @@ class Client(db.Model): password_setup_token = db.Column(db.String(100), nullable=True, index=True) # Token for password setup/reset password_setup_token_expires = db.Column(db.DateTime, nullable=True) # Token expiration time + # Custom fields for flexible data storage (e.g., debtor_number, ERP IDs, etc.) + custom_fields = db.Column(db.JSON, nullable=True) + # Relationships projects = db.relationship("Project", backref="client_obj", lazy="dynamic", cascade="all, delete-orphan") + time_entries = db.relationship("TimeEntry", backref="client", lazy="dynamic", cascade="all, delete-orphan") def __init__( self, @@ -197,6 +202,50 @@ class Client(db.Model): self.status = "active" self.updated_at = datetime.utcnow() + def get_custom_field(self, key, default=None): + """Get a custom field value by key""" + 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 client's custom fields""" + from .link_template import LinkTemplate + + if not self.custom_fields: + return [] + + links = [] + templates = LinkTemplate.get_active_templates() + + for template in templates: + field_value = self.get_custom_field(template.field_key) + if field_value: + url = template.render_url(field_value) + if url: + links.append({ + "id": template.id, + "name": template.name, + "url": url, + "icon": template.icon, + "description": template.description, + }) + + return links + def to_dict(self): """Convert client to dictionary for JSON serialization""" return { @@ -216,6 +265,7 @@ class Client(db.Model): float(self.prepaid_hours_monthly) if self.prepaid_hours_monthly is not None else None ), "prepaid_reset_day": self.prepaid_reset_day, + "custom_fields": self.custom_fields or {}, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } diff --git a/app/models/link_template.py b/app/models/link_template.py new file mode 100644 index 0000000..4759c10 --- /dev/null +++ b/app/models/link_template.py @@ -0,0 +1,62 @@ +"""Link Template model for storing URL templates with field placeholders""" + +from datetime import datetime +from app import db + + +class LinkTemplate(db.Model): + """Model for storing URL templates that can use custom field values from clients""" + + __tablename__ = "link_templates" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, nullable=True) + url_template = db.Column(db.String(1000), nullable=False) # URL with {value} placeholder + icon = db.Column(db.String(50), nullable=True) # Font Awesome icon class (e.g., 'fas fa-link') + field_key = db.Column(db.String(100), nullable=False) # Key in custom_fields to use (e.g., 'debtor_number') + is_active = db.Column(db.Boolean, default=True, nullable=False, index=True) + order = db.Column(db.Integer, default=0, nullable=False) # Display order + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + creator = db.relationship("User", backref="link_templates", foreign_keys=[created_by]) + + def __repr__(self): + return f"" + + def render_url(self, field_value): + """Render the URL template with the given field value""" + if not field_value: + return None + try: + return self.url_template.format(value=field_value) + except (KeyError, ValueError): + return None + + def to_dict(self): + """Convert link template to dictionary for JSON serialization""" + return { + "id": self.id, + "name": self.name, + "description": self.description, + "url_template": self.url_template, + "icon": self.icon, + "field_key": self.field_key, + "is_active": self.is_active, + "order": self.order, + "created_by": self.created_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + @classmethod + def get_active_templates(cls, field_key=None): + """Get active link templates, optionally filtered by field_key""" + query = cls.query.filter_by(is_active=True) + if field_key: + query = query.filter_by(field_key=field_key) + return query.order_by(cls.order, cls.name).all() + diff --git a/app/models/reporting.py b/app/models/reporting.py index 3a27455..62af845 100644 --- a/app/models/reporting.py +++ b/app/models/reporting.py @@ -37,5 +37,9 @@ class ReportEmailSchedule(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + # Relationships + saved_view = db.relationship("SavedReportView", backref="schedules") + creator = db.relationship("User", foreign_keys=[created_by]) + def __repr__(self): return f"" diff --git a/app/models/settings.py b/app/models/settings.py index 20be693..45f39a7 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -44,6 +44,40 @@ class Settings(db.Model): # Privacy and analytics settings allow_analytics = db.Column(db.Boolean, default=True, nullable=False) # Controls system info sharing for analytics + # System-wide UI feature flags - control which features are available for users to customize + # Calendar section + ui_allow_calendar = db.Column(db.Boolean, default=True, nullable=False) + + # Time Tracking section items + ui_allow_project_templates = db.Column(db.Boolean, default=True, nullable=False) + ui_allow_gantt_chart = db.Column(db.Boolean, default=True, nullable=False) + ui_allow_kanban_board = db.Column(db.Boolean, default=True, nullable=False) + ui_allow_weekly_goals = db.Column(db.Boolean, default=True, nullable=False) + + # CRM section + ui_allow_quotes = db.Column(db.Boolean, default=True, nullable=False) + + # Finance & Expenses section items + ui_allow_reports = db.Column(db.Boolean, default=True, nullable=False) + ui_allow_report_builder = db.Column(db.Boolean, default=True, nullable=False) + ui_allow_scheduled_reports = db.Column(db.Boolean, default=True, nullable=False) + ui_allow_invoice_approvals = db.Column(db.Boolean, default=True, nullable=False) + ui_allow_payment_gateways = db.Column(db.Boolean, default=True, nullable=False) + ui_allow_recurring_invoices = db.Column(db.Boolean, default=True, nullable=False) + ui_allow_payments = db.Column(db.Boolean, default=True, nullable=False) + ui_allow_mileage = db.Column(db.Boolean, default=True, nullable=False) + ui_allow_per_diem = db.Column(db.Boolean, default=True, nullable=False) + ui_allow_budget_alerts = db.Column(db.Boolean, default=True, nullable=False) + + # Inventory section + ui_allow_inventory = db.Column(db.Boolean, default=True, nullable=False) + + # Analytics + ui_allow_analytics = db.Column(db.Boolean, default=True, nullable=False) + + # Tools & Data section + ui_allow_tools = db.Column(db.Boolean, default=True, nullable=False) + # Kiosk mode settings kiosk_mode_enabled = db.Column(db.Boolean, default=False, nullable=False) kiosk_auto_logout_minutes = db.Column(db.Integer, default=15, nullable=False) @@ -251,6 +285,26 @@ class Settings(db.Model): "github_client_secret_set": bool(self.github_client_secret), # Don't expose actual secret "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, + # UI feature flags (system-wide) + "ui_allow_calendar": getattr(self, "ui_allow_calendar", True), + "ui_allow_project_templates": getattr(self, "ui_allow_project_templates", True), + "ui_allow_gantt_chart": getattr(self, "ui_allow_gantt_chart", True), + "ui_allow_kanban_board": getattr(self, "ui_allow_kanban_board", True), + "ui_allow_weekly_goals": getattr(self, "ui_allow_weekly_goals", True), + "ui_allow_quotes": getattr(self, "ui_allow_quotes", True), + "ui_allow_reports": getattr(self, "ui_allow_reports", True), + "ui_allow_report_builder": getattr(self, "ui_allow_report_builder", True), + "ui_allow_scheduled_reports": getattr(self, "ui_allow_scheduled_reports", True), + "ui_allow_invoice_approvals": getattr(self, "ui_allow_invoice_approvals", True), + "ui_allow_payment_gateways": getattr(self, "ui_allow_payment_gateways", True), + "ui_allow_recurring_invoices": getattr(self, "ui_allow_recurring_invoices", True), + "ui_allow_payments": getattr(self, "ui_allow_payments", True), + "ui_allow_mileage": getattr(self, "ui_allow_mileage", True), + "ui_allow_per_diem": getattr(self, "ui_allow_per_diem", True), + "ui_allow_budget_alerts": getattr(self, "ui_allow_budget_alerts", True), + "ui_allow_inventory": getattr(self, "ui_allow_inventory", True), + "ui_allow_analytics": getattr(self, "ui_allow_analytics", True), + "ui_allow_tools": getattr(self, "ui_allow_tools", True), } @classmethod @@ -266,11 +320,25 @@ class Settings(db.Model): return settings except Exception as e: # Handle case where columns don't exist yet (migration not run) - # Log but don't fail - return fallback instance + # Check if it's a column error - if so, it's expected during migrations + error_str = str(e) + is_column_error = ( + "UndefinedColumn" in error_str or + "does not exist" in error_str.lower() or + "no such column" in error_str.lower() + ) + import logging - logger = logging.getLogger(__name__) - logger.warning(f"Could not query settings (migration may not be run): {e}") + + if is_column_error: + # This is expected during migrations when schema is incomplete + # Only log at debug level to avoid cluttering logs + logger.debug(f"Settings table schema incomplete (migration may be pending): {error_str.split('LINE')[0] if 'LINE' in error_str else error_str}") + else: + # Other errors should be logged as warnings + logger.warning(f"Could not query settings: {e}") + # Rollback the failed transaction try: db.session.rollback() diff --git a/app/models/time_entry.py b/app/models/time_entry.py index b4457a1..1bccc22 100644 --- a/app/models/time_entry.py +++ b/app/models/time_entry.py @@ -20,7 +20,8 @@ class TimeEntry(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) - project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=True, index=True) task_id = db.Column(db.Integer, db.ForeignKey("tasks.id"), nullable=True, index=True) start_time = db.Column(db.DateTime, nullable=False, index=True) end_time = db.Column(db.DateTime, nullable=True, index=True) @@ -34,12 +35,14 @@ class TimeEntry(db.Model): # Relationships # user and project relationships are defined via backref in their respective models + # client relationship is defined via backref in Client model # task relationship is defined via backref in Task model def __init__( self, user_id=None, project_id=None, + client_id=None, start_time=None, end_time=None, task_id=None, @@ -54,10 +57,11 @@ class TimeEntry(db.Model): Args: user_id: ID of the user who created this entry - project_id: ID of the project this entry is associated with + project_id: ID of the project this entry is associated with (optional if client_id is provided) + client_id: ID of the client this entry is directly billed to (optional if project_id is provided) start_time: When the time entry started end_time: When the time entry ended (None for active timers) - task_id: Optional task ID + task_id: Optional task ID (only valid when project_id is provided) notes: Optional notes/description tags: Optional comma-separated tags source: Source of the entry ('manual' or 'auto') @@ -69,6 +73,8 @@ class TimeEntry(db.Model): self.user_id = user_id if project_id is not None: self.project_id = project_id + if client_id is not None: + self.client_id = client_id if task_id is not None: self.task_id = task_id if start_time is not None: @@ -76,6 +82,14 @@ class TimeEntry(db.Model): if end_time is not None: self.end_time = end_time + # Validate that either project_id or client_id is provided + if not self.project_id and not self.client_id: + raise ValueError("Either project_id or client_id must be provided") + + # Validate that task_id is only provided when project_id is set + if self.task_id and not self.project_id: + raise ValueError("task_id can only be set when project_id is provided") + self.notes = notes.strip() if notes else None self.tags = tags.strip() if tags else None self.source = source @@ -90,8 +104,13 @@ class TimeEntry(db.Model): def __repr__(self): user_name = self.user.username if self.user else "deleted_user" - project_name = self.project.name if self.project else "deleted_project" - return f"" + if self.project: + target = self.project.name + elif self.client: + target = self.client.name + else: + target = "unknown" + return f"" @property def is_active(self): @@ -211,6 +230,7 @@ class TimeEntry(db.Model): "id": self.id, "user_id": self.user_id, "project_id": self.project_id, + "client_id": self.client_id, "task_id": self.task_id, "start_time": self.start_time.isoformat() if self.start_time else None, "end_time": self.end_time.isoformat() if self.end_time else None, @@ -227,6 +247,7 @@ class TimeEntry(db.Model): "updated_at": self.updated_at.isoformat() if self.updated_at else None, "user": self.user.username if self.user else None, "project": self.project.name if self.project else None, + "client": self.client.name if self.client else None, "task": self.task.name if self.task else None, } @@ -241,7 +262,7 @@ class TimeEntry(db.Model): return cls.query.filter_by(user_id=user_id, end_time=None).first() @classmethod - def get_entries_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None): + def get_entries_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None, client_id=None): """Get time entries for a specific period with optional filters""" query = cls.query.filter(cls.end_time.isnot(None)) @@ -257,11 +278,14 @@ class TimeEntry(db.Model): if project_id: query = query.filter(cls.project_id == project_id) + if client_id: + query = query.filter(cls.client_id == client_id) + return query.order_by(cls.start_time.desc()).all() @classmethod def get_total_hours_for_period( - cls, start_date=None, end_date=None, user_id=None, project_id=None, billable_only=False + cls, start_date=None, end_date=None, user_id=None, project_id=None, client_id=None, billable_only=False ): """Calculate total hours for a period with optional filters""" query = db.session.query(db.func.sum(cls.duration_seconds)) @@ -278,6 +302,9 @@ class TimeEntry(db.Model): if project_id: query = query.filter(cls.project_id == project_id) + if client_id: + query = query.filter(cls.client_id == client_id) + if billable_only: query = query.filter(cls.billable == True) diff --git a/app/models/user.py b/app/models/user.py index 386b198..3c7bbb1 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -54,6 +54,41 @@ class User(UserMixin, db.Model): db.Integer, db.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True ) # Link user to a client for portal access + # UI feature flags - allow users to customize which features are visible + # All default to True (enabled) for backward compatibility + # Calendar section + ui_show_calendar = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Calendar section + + # Time Tracking section items + ui_show_project_templates = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Project Templates + ui_show_gantt_chart = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Gantt Chart + ui_show_kanban_board = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Kanban Board + ui_show_weekly_goals = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Weekly Goals + + # CRM section + ui_show_quotes = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Quotes + + # Finance & Expenses section items + ui_show_reports = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Reports + ui_show_report_builder = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Report Builder + ui_show_scheduled_reports = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Scheduled Reports + ui_show_invoice_approvals = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Invoice Approvals + ui_show_payment_gateways = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Payment Gateways + ui_show_recurring_invoices = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Recurring Invoices + ui_show_payments = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Payments + ui_show_mileage = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Mileage + ui_show_per_diem = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Per Diem + ui_show_budget_alerts = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Budget Alerts + + # Inventory section + ui_show_inventory = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Inventory section + + # Analytics + ui_show_analytics = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Analytics + + # Tools & Data section + ui_show_tools = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Tools & Data section + # Relationships time_entries = db.relationship("TimeEntry", backref="user", lazy="dynamic", cascade="all, delete-orphan") project_costs = db.relationship("ProjectCost", backref="user", lazy="dynamic", cascade="all, delete-orphan") diff --git a/app/repositories/time_entry_repository.py b/app/repositories/time_entry_repository.py index 9eedbc3..a82c7cc 100644 --- a/app/repositories/time_entry_repository.py +++ b/app/repositories/time_entry_repository.py @@ -29,7 +29,12 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): query = self.model.query.filter_by(user_id=user_id) if include_relations: - query = query.options(joinedload(TimeEntry.project), joinedload(TimeEntry.task), joinedload(TimeEntry.user)) + query = query.options( + joinedload(TimeEntry.project), + joinedload(TimeEntry.client), + joinedload(TimeEntry.task), + joinedload(TimeEntry.user) + ) query = query.order_by(TimeEntry.start_time.desc()) @@ -45,7 +50,12 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): query = self.model.query.filter_by(project_id=project_id) if include_relations: - query = query.options(joinedload(TimeEntry.user), joinedload(TimeEntry.task)) + query = query.options( + joinedload(TimeEntry.user), + joinedload(TimeEntry.project), + joinedload(TimeEntry.client), + joinedload(TimeEntry.task) + ) query = query.order_by(TimeEntry.start_time.desc()) @@ -60,6 +70,7 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): end_date: datetime, user_id: Optional[int] = None, project_id: Optional[int] = None, + client_id: Optional[int] = None, include_relations: bool = False, ) -> List[TimeEntry]: """Get time entries within a date range""" @@ -71,8 +82,16 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): if project_id: query = query.filter_by(project_id=project_id) + if client_id: + query = query.filter_by(client_id=client_id) + if include_relations: - query = query.options(joinedload(TimeEntry.user), joinedload(TimeEntry.project), joinedload(TimeEntry.task)) + query = query.options( + joinedload(TimeEntry.user), + joinedload(TimeEntry.project), + joinedload(TimeEntry.client), + joinedload(TimeEntry.task) + ) return query.order_by(TimeEntry.start_time.desc()).all() @@ -80,6 +99,7 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): self, user_id: Optional[int] = None, project_id: Optional[int] = None, + client_id: Optional[int] = None, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, ) -> List[TimeEntry]: @@ -92,6 +112,9 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): if project_id: query = query.filter_by(project_id=project_id) + if client_id: + query = query.filter_by(client_id=client_id) + if start_date: query = query.filter(TimeEntry.start_time >= start_date) @@ -112,7 +135,8 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): def create_timer( self, user_id: int, - project_id: int, + project_id: Optional[int] = None, + client_id: Optional[int] = None, task_id: Optional[int] = None, notes: Optional[str] = None, source: str = TimeEntrySource.AUTO.value, @@ -121,7 +145,13 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): from app.models.time_entry import local_now entry = self.model( - user_id=user_id, project_id=project_id, task_id=task_id, start_time=local_now(), notes=notes, source=source + user_id=user_id, + project_id=project_id, + client_id=client_id, + task_id=task_id, + start_time=local_now(), + notes=notes, + source=source ) db.session.add(entry) return entry @@ -129,9 +159,10 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): def create_manual_entry( self, user_id: int, - project_id: int, - start_time: datetime, - end_time: datetime, + project_id: Optional[int] = None, + client_id: Optional[int] = None, + start_time: datetime = None, + end_time: datetime = None, task_id: Optional[int] = None, notes: Optional[str] = None, tags: Optional[str] = None, @@ -141,6 +172,7 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): entry = self.model( user_id=user_id, project_id=project_id, + client_id=client_id, task_id=task_id, start_time=start_time, end_time=end_time, @@ -157,6 +189,7 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): self, user_id: Optional[int] = None, project_id: Optional[int] = None, + client_id: Optional[int] = None, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, billable_only: bool = False, @@ -172,6 +205,9 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): if project_id: query = query.filter_by(project_id=project_id) + if client_id: + query = query.filter_by(client_id=client_id) + if start_date: query = query.filter(TimeEntry.start_time >= start_date) diff --git a/app/routes/admin.py b/app/routes/admin.py index c7c2b94..b971ed8 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -448,6 +448,44 @@ def settings(): # Kiosk columns don't exist yet (migration not run) pass + # Update system-wide UI feature flags (if columns exist) + try: + # Calendar + settings_obj.ui_allow_calendar = request.form.get("ui_allow_calendar") == "on" + + # Time Tracking + settings_obj.ui_allow_project_templates = request.form.get("ui_allow_project_templates") == "on" + settings_obj.ui_allow_gantt_chart = request.form.get("ui_allow_gantt_chart") == "on" + settings_obj.ui_allow_kanban_board = request.form.get("ui_allow_kanban_board") == "on" + settings_obj.ui_allow_weekly_goals = request.form.get("ui_allow_weekly_goals") == "on" + + # CRM + settings_obj.ui_allow_quotes = request.form.get("ui_allow_quotes") == "on" + + # Finance & Expenses + settings_obj.ui_allow_reports = request.form.get("ui_allow_reports") == "on" + settings_obj.ui_allow_report_builder = request.form.get("ui_allow_report_builder") == "on" + settings_obj.ui_allow_scheduled_reports = request.form.get("ui_allow_scheduled_reports") == "on" + settings_obj.ui_allow_invoice_approvals = request.form.get("ui_allow_invoice_approvals") == "on" + settings_obj.ui_allow_payment_gateways = request.form.get("ui_allow_payment_gateways") == "on" + settings_obj.ui_allow_recurring_invoices = request.form.get("ui_allow_recurring_invoices") == "on" + settings_obj.ui_allow_payments = request.form.get("ui_allow_payments") == "on" + settings_obj.ui_allow_mileage = request.form.get("ui_allow_mileage") == "on" + settings_obj.ui_allow_per_diem = request.form.get("ui_allow_per_diem") == "on" + settings_obj.ui_allow_budget_alerts = request.form.get("ui_allow_budget_alerts") == "on" + + # Inventory + settings_obj.ui_allow_inventory = request.form.get("ui_allow_inventory") == "on" + + # Analytics + settings_obj.ui_allow_analytics = request.form.get("ui_allow_analytics") == "on" + + # Tools & Data + settings_obj.ui_allow_tools = request.form.get("ui_allow_tools") == "on" + except AttributeError: + # UI allow columns don't exist yet (migration not run) + pass + # Update integration OAuth credentials (if columns exist) try: if "jira_client_id" in request.form: diff --git a/app/routes/api.py b/app/routes/api.py index 38e7f3a..4482518 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -773,8 +773,12 @@ def delete_saved_filter(filter_id): @login_required def create_entry(): """Create a finished time entry (used by calendar drag-create).""" + from app.models import Client + from app.services import TimeTrackingService + data = request.get_json() or {} project_id = data.get("project_id") + client_id = data.get("client_id") task_id = data.get("task_id") start_time_str = data.get("start_time") end_time_str = data.get("end_time") @@ -782,18 +786,11 @@ def create_entry(): tags = (data.get("tags") or "").strip() or None billable = bool(data.get("billable", True)) - if not (project_id and start_time_str and end_time_str): - return jsonify({"error": "project_id, start_time, end_time are required"}), 400 + if not (start_time_str and end_time_str): + return jsonify({"error": "start_time and end_time are required"}), 400 - # Validate project - project = Project.query.filter_by(id=project_id, status="active").first() - if not project: - return jsonify({"error": "Invalid project"}), 400 - - if task_id: - task = Task.query.filter_by(id=task_id, project_id=project_id).first() - if not task: - return jsonify({"error": "Invalid task for selected project"}), 400 + if not project_id and not client_id: + return jsonify({"error": "Either project_id or client_id is required"}), 400 def parse_iso_local(s: str): try: @@ -812,24 +809,28 @@ def create_entry(): if not (start_dt and end_dt) or end_dt <= start_dt: return jsonify({"error": "Invalid start/end time"}), 400 - entry = TimeEntry( + # Use service to create entry (handles validation) + time_tracking_service = TimeTrackingService() + result = time_tracking_service.create_manual_entry( user_id=current_user.id if not current_user.is_admin else (data.get("user_id") or current_user.id), project_id=project_id, - task_id=task_id, + client_id=client_id, start_time=start_dt, end_time=end_dt, + task_id=task_id, notes=notes, tags=tags, - source="manual", billable=billable, ) - db.session.add(entry) - if not safe_commit("api_create_entry", {"project_id": project_id}): - return jsonify({"error": "Database error while creating entry"}), 500 + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not create time entry")}), 400 + + entry = result.get("entry") payload = entry.to_dict() payload["project_name"] = entry.project.name if entry.project else None - return jsonify({"success": True, "entry": payload}) + payload["client_name"] = entry.client.name if entry.client else None + return jsonify({"success": True, "entry": payload}), 201 @api_bp.route("/api/entries/bulk", methods=["POST"]) diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index 2389c0f..dba0362 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -623,8 +623,8 @@ def create_time_entry(): data = request.get_json() or {} # Validate required fields - if not data.get("project_id"): - return jsonify({"error": "project_id is required"}), 400 + if not data.get("project_id") and not data.get("client_id"): + return jsonify({"error": "Either project_id or client_id is required"}), 400 if not data.get("start_time"): return jsonify({"error": "start_time is required"}), 400 @@ -643,7 +643,8 @@ def create_time_entry(): time_tracking_service = TimeTrackingService() result = time_tracking_service.create_manual_entry( user_id=g.api_user.id, - project_id=data["project_id"], + project_id=data.get("project_id"), + client_id=data.get("client_id"), start_time=start_time, end_time=end_time or start_time, # Service requires end_time task_id=data.get("task_id"), @@ -1241,6 +1242,7 @@ def create_client(): phone=data.get("phone"), address=data.get("address"), default_hourly_rate=Decimal(str(data["default_hourly_rate"])) if data.get("default_hourly_rate") else None, + custom_fields=data.get("custom_fields"), ) if not result.get("success"): diff --git a/app/routes/clients.py b/app/routes/clients.py index a0e2f51..7ffcd51 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -12,6 +12,7 @@ from app.utils.timezone import convert_app_datetime_to_user from app.utils.email import send_client_portal_password_setup_email import csv import io +import json clients_bp = Blueprint("clients", __name__) @@ -156,6 +157,18 @@ def create_client(): flash(message, "error") return render_template("clients/create.html") + # Parse custom fields from individual key/value inputs + # Format: custom_field_key_0 / custom_field_value_0, custom_field_key_1 / ... + custom_fields = {} + for form_key in request.form.keys(): + if not form_key.startswith("custom_field_key_"): + continue + index = form_key.rsplit("_", 1)[-1] + field_key = request.form.get(form_key, "").strip() + field_value = request.form.get(f"custom_field_value_{index}", "").strip() + if field_key and field_value: + custom_fields[field_key] = field_value + # Create client client = Client( name=name, @@ -168,6 +181,8 @@ def create_client(): prepaid_hours_monthly=prepaid_hours_monthly, prepaid_reset_day=prepaid_reset_day, ) + if custom_fields: + client.custom_fields = custom_fields db.session.add(client) if not safe_commit("create_client", {"name": name}): @@ -244,6 +259,9 @@ def view_client(client_id): "remaining_hours": float(remaining_hours), } + # Get rendered links from link templates + rendered_links = client.get_rendered_links() + return render_template( "clients/view.html", client=client, @@ -251,6 +269,7 @@ def view_client(client_id): contacts=contacts, primary_contact=primary_contact, prepaid_overview=prepaid_overview, + rendered_links=rendered_links, ) @@ -330,6 +349,18 @@ def edit_client(client_id): flash(_("This portal username is already in use by another client."), "error") return render_template("clients/edit.html", client=client) + # Parse custom fields from individual key/value inputs. + # This builds a fresh dict on every save so edits/removals/additions all work. + custom_fields = {} + for form_key in request.form.keys(): + if not form_key.startswith("custom_field_key_"): + continue + index = form_key.rsplit("_", 1)[-1] + field_key = request.form.get(form_key, "").strip() + field_value = request.form.get(f"custom_field_value_{index}", "").strip() + if field_key and field_value: + custom_fields[field_key] = field_value + # Update client client.name = name client.description = description @@ -341,6 +372,7 @@ def edit_client(client_id): client.prepaid_hours_monthly = prepaid_hours_monthly client.prepaid_reset_day = prepaid_reset_day client.portal_enabled = portal_enabled + client.custom_fields = custom_fields if custom_fields else None # Update portal credentials if portal_enabled: diff --git a/app/routes/link_templates.py b/app/routes/link_templates.py new file mode 100644 index 0000000..d61cc91 --- /dev/null +++ b/app/routes/link_templates.py @@ -0,0 +1,145 @@ +"""Link Template routes for managing URL templates""" + +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify +from flask_babel import gettext as _ +from flask_login import login_required, current_user +from app import db +from app.models import LinkTemplate +from app.utils.db import safe_commit +from app.utils.permissions import admin_or_permission_required +from datetime import datetime + +link_templates_bp = Blueprint("link_templates", __name__) + + +@link_templates_bp.route("/admin/link-templates") +@login_required +@admin_or_permission_required("manage_settings") +def list_link_templates(): + """List all link templates""" + templates = LinkTemplate.query.order_by(LinkTemplate.order, LinkTemplate.name).all() + return render_template("admin/link_templates/list.html", templates=templates) + + +@link_templates_bp.route("/admin/link-templates/create", methods=["GET", "POST"]) +@login_required +@admin_or_permission_required("manage_settings") +def create_link_template(): + """Create a new link template""" + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + url_template = request.form.get("url_template", "").strip() + icon = request.form.get("icon", "").strip() + field_key = request.form.get("field_key", "").strip() + is_active = request.form.get("is_active") == "on" + order = request.form.get("order", "0", type=int) + + # Validate required fields + if not name: + flash(_("Template name is required"), "error") + return render_template("admin/link_templates/form.html", template=None) + + if not url_template: + flash(_("URL template is required"), "error") + return render_template("admin/link_templates/form.html", template=None) + + if "{value}" not in url_template: + flash(_("URL template must contain {value} placeholder"), "error") + return render_template("admin/link_templates/form.html", template=None) + + if not field_key: + flash(_("Field key is required"), "error") + return render_template("admin/link_templates/form.html", template=None) + + # Create template + template = LinkTemplate( + name=name, + description=description, + url_template=url_template, + icon=icon or "fas fa-external-link-alt", + field_key=field_key, + is_active=is_active, + order=order, + created_by=current_user.id, + ) + + db.session.add(template) + if not safe_commit("create_link_template", {"name": name}): + flash(_("Could not create link template due to a database error."), "error") + return render_template("admin/link_templates/form.html", template=None) + + flash(_("Link template created successfully"), "success") + return redirect(url_for("link_templates.list_link_templates")) + + return render_template("admin/link_templates/form.html", template=None) + + +@link_templates_bp.route("/admin/link-templates//edit", methods=["GET", "POST"]) +@login_required +@admin_or_permission_required("manage_settings") +def edit_link_template(template_id): + """Edit a link template""" + template = LinkTemplate.query.get_or_404(template_id) + + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + url_template = request.form.get("url_template", "").strip() + icon = request.form.get("icon", "").strip() + field_key = request.form.get("field_key", "").strip() + is_active = request.form.get("is_active") == "on" + order = request.form.get("order", "0", type=int) + + # Validate required fields + if not name: + flash(_("Template name is required"), "error") + return render_template("admin/link_templates/form.html", template=template) + + if not url_template: + flash(_("URL template is required"), "error") + return render_template("admin/link_templates/form.html", template=template) + + if "{value}" not in url_template: + flash(_("URL template must contain {value} placeholder"), "error") + return render_template("admin/link_templates/form.html", template=template) + + if not field_key: + flash(_("Field key is required"), "error") + return render_template("admin/link_templates/form.html", template=template) + + # Update template + template.name = name + template.description = description + template.url_template = url_template + template.icon = icon or "fas fa-external-link-alt" + template.field_key = field_key + template.is_active = is_active + template.order = order + template.updated_at = datetime.utcnow() + + if not safe_commit("edit_link_template", {"template_id": template.id}): + flash(_("Could not update link template due to a database error."), "error") + return render_template("admin/link_templates/form.html", template=template) + + flash(_("Link template updated successfully"), "success") + return redirect(url_for("link_templates.list_link_templates")) + + return render_template("admin/link_templates/form.html", template=template) + + +@link_templates_bp.route("/admin/link-templates//delete", methods=["POST"]) +@login_required +@admin_or_permission_required("manage_settings") +def delete_link_template(template_id): + """Delete a link template""" + template = LinkTemplate.query.get_or_404(template_id) + + db.session.delete(template) + if not safe_commit("delete_link_template", {"template_id": template.id}): + flash(_("Could not delete link template due to a database error."), "error") + else: + flash(_("Link template deleted successfully"), "success") + + return redirect(url_for("link_templates.list_link_templates")) + diff --git a/app/routes/main.py b/app/routes/main.py index 01d634e..c628cf0 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -45,9 +45,11 @@ def dashboard(): recent_entries = time_entry_repo.get_by_user(user_id=current_user.id, limit=10, include_relations=True) # Get active projects for timer dropdown (using repository) - from app.repositories import ProjectRepository + from app.repositories import ProjectRepository, ClientRepository project_repo = ProjectRepository() + client_repo = ClientRepository() active_projects = project_repo.get_active_projects() + active_clients = client_repo.get_active_clients() # Get user statistics using analytics service from app.services import AnalyticsService @@ -107,6 +109,7 @@ def dashboard(): "active_timer": active_timer, "recent_entries": recent_entries, "active_projects": active_projects, + "active_clients": active_clients, "today_hours": today_hours, "week_hours": week_hours, "month_hours": month_hours, diff --git a/app/routes/reports.py b/app/routes/reports.py index 8acfbc8..309be86 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -812,3 +812,268 @@ def export_project_excel(): as_attachment=True, download_name=filename, ) + + +@reports_bp.route("/reports/user/export/excel") +@login_required +def export_user_excel(): + """Export user report as Excel file""" + user_id = request.args.get("user_id", type=int) + project_id = request.args.get("project_id", type=int) + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + + # Parse dates + if not start_date: + start_date = (datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d") + if not end_date: + end_date = datetime.utcnow().strftime("%Y-%m-%d") + + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) + except ValueError: + flash(_("Invalid date format"), "error") + return redirect(url_for("reports.user_report")) + + # Get time entries + query = TimeEntry.query.filter( + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt + ) + + if user_id: + query = query.filter(TimeEntry.user_id == user_id) + + if project_id: + query = query.filter(TimeEntry.project_id == project_id) + + entries = query.order_by(TimeEntry.start_time.desc()).all() + + # Group by user + user_totals = {} + for entry in entries: + username = entry.user.display_name if entry.user else "Unknown" + if username not in user_totals: + user_totals[username] = { + "hours": 0, + "billable_hours": 0, + "user_obj": entry.user, + } + user_totals[username]["hours"] += entry.duration_hours + if entry.billable: + user_totals[username]["billable_hours"] += entry.duration_hours + + # Calculate overtime + from app.utils.overtime import calculate_period_overtime + + for username, data in user_totals.items(): + if data["user_obj"]: + overtime_data = calculate_period_overtime(data["user_obj"], start_dt.date(), end_dt.date()) + data["regular_hours"] = overtime_data["regular_hours"] + data["overtime_hours"] = overtime_data["overtime_hours"] + data["days_with_overtime"] = overtime_data["days_with_overtime"] + else: + data["regular_hours"] = data["hours"] + data["overtime_hours"] = 0 + data["days_with_overtime"] = 0 + + # Create Excel file + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill, Border, Side + from openpyxl.utils import get_column_letter + + wb = Workbook() + ws = wb.active + ws.title = "User Report" + + # Styles + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + border = Border( + left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin") + ) + + # Title + ws.merge_cells("A1:F1") + title_cell = ws["A1"] + title_cell.value = f"User Report: {start_date} to {end_date}" + title_cell.font = Font(bold=True, size=14) + title_cell.alignment = Alignment(horizontal="center") + + # Headers + headers = ["User", "Total Hours", "Regular Hours", "Overtime Hours", "Billable Hours", "Days with Overtime"] + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=3, column=col_num) + cell.value = header + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal="center", vertical="center") + cell.border = border + + # Data rows + row_num = 4 + for username, data in sorted(user_totals.items()): + ws.cell(row=row_num, column=1).value = username + ws.cell(row=row_num, column=2).value = round(data["hours"], 2) + ws.cell(row=row_num, column=3).value = round(data.get("regular_hours", data["hours"]), 2) + ws.cell(row=row_num, column=4).value = round(data.get("overtime_hours", 0), 2) + ws.cell(row=row_num, column=5).value = round(data["billable_hours"], 2) + ws.cell(row=row_num, column=6).value = data.get("days_with_overtime", 0) + + for col_num in range(1, len(headers) + 1): + cell = ws.cell(row=row_num, column=col_num) + cell.border = border + if col_num > 1: + cell.number_format = "0.00" + + row_num += 1 + + # Auto-adjust column widths + for col_num, header in enumerate(headers, 1): + column_letter = get_column_letter(col_num) + ws.column_dimensions[column_letter].width = max(len(header), 15) + + # Save to BytesIO + output = io.BytesIO() + wb.save(output) + output.seek(0) + + filename = f"user_report_{start_date}_{end_date}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + log_event("export.excel", user_id=current_user.id, export_type="user_report", num_users=len(user_totals)) + track_event(current_user.id, "export.excel", {"export_type": "user_report", "num_users": len(user_totals)}) + + return send_file( + output, + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + as_attachment=True, + download_name=filename, + ) + + +@reports_bp.route("/reports/task/export/excel") +@login_required +def export_task_excel(): + """Export task report as Excel file""" + project_id = request.args.get("project_id", type=int) + user_id = request.args.get("user_id", type=int) + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + + # Parse dates + if not start_date: + start_date = (datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d") + if not end_date: + end_date = datetime.utcnow().strftime("%Y-%m-%d") + + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) + except ValueError: + flash(_("Invalid date format"), "error") + return redirect(url_for("reports.task_report")) + + # Get tasks + tasks_query = Task.query.filter(Task.status == "done") + if project_id: + tasks_query = tasks_query.filter(Task.project_id == project_id) + tasks_query = tasks_query.filter(Task.completed_at.isnot(None)) + tasks_query = tasks_query.filter(Task.completed_at >= start_dt, Task.completed_at <= end_dt) + if user_id: + tasks_query = tasks_query.join(TimeEntry, TimeEntry.task_id == Task.id).filter(TimeEntry.user_id == user_id) + tasks = tasks_query.order_by(Task.completed_at.desc()).all() + + # Compute hours per task + task_rows = [] + for task in tasks: + te_query = TimeEntry.query.filter( + TimeEntry.task_id == task.id, + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_dt, + TimeEntry.start_time <= end_dt, + ) + if project_id: + te_query = te_query.filter(TimeEntry.project_id == project_id) + if user_id: + te_query = te_query.filter(TimeEntry.user_id == user_id) + + entries = te_query.all() + hours = sum(e.duration_hours for e in entries) + + task_rows.append({ + "task": task, + "project": task.project, + "completed_at": task.completed_at, + "hours": round(hours, 2), + }) + + # Create Excel file + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill, Border, Side + from openpyxl.utils import get_column_letter + + wb = Workbook() + ws = wb.active + ws.title = "Task Report" + + # Styles + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + border = Border( + left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin") + ) + + # Title + ws.merge_cells("A1:D1") + title_cell = ws["A1"] + title_cell.value = f"Task Report: {start_date} to {end_date}" + title_cell.font = Font(bold=True, size=14) + title_cell.alignment = Alignment(horizontal="center") + + # Headers + headers = ["Task", "Project", "Completed At", "Hours"] + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=3, column=col_num) + cell.value = header + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal="center", vertical="center") + cell.border = border + + # Data rows + row_num = 4 + for row_data in task_rows: + ws.cell(row=row_num, column=1).value = row_data["task"].name + ws.cell(row=row_num, column=2).value = row_data["project"].name if row_data["project"] else "N/A" + ws.cell(row=row_num, column=3).value = row_data["completed_at"].strftime('%Y-%m-%d') if row_data["completed_at"] else "N/A" + ws.cell(row=row_num, column=4).value = row_data["hours"] + + for col_num in range(1, len(headers) + 1): + cell = ws.cell(row=row_num, column=col_num) + cell.border = border + if col_num == 4: + cell.number_format = "0.00" + + row_num += 1 + + # Auto-adjust column widths + for col_num, header in enumerate(headers, 1): + column_letter = get_column_letter(col_num) + ws.column_dimensions[column_letter].width = max(len(header), 15) + + # Save to BytesIO + output = io.BytesIO() + wb.save(output) + output.seek(0) + + filename = f"task_report_{start_date}_{end_date}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + log_event("export.excel", user_id=current_user.id, export_type="task_report", num_tasks=len(task_rows)) + track_event(current_user.id, "export.excel", {"export_type": "task_report", "num_tasks": len(task_rows)}) + + return send_file( + output, + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + as_attachment=True, + download_name=filename, + ) diff --git a/app/routes/scheduled_reports.py b/app/routes/scheduled_reports.py index 9b3d7e4..1b27fc7 100644 --- a/app/routes/scheduled_reports.py +++ b/app/routes/scheduled_reports.py @@ -2,15 +2,48 @@ Routes for scheduled reports management. """ -from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify from flask_babel import gettext as _ from flask_login import login_required, current_user -from app.models import SavedReportView +from app.models import SavedReportView, ReportEmailSchedule from app.services.scheduled_report_service import ScheduledReportService scheduled_reports_bp = Blueprint("scheduled_reports", __name__) +@scheduled_reports_bp.route("/api/reports/scheduled", methods=["GET"]) +@login_required +def api_list_scheduled(): + """Get scheduled reports as JSON""" + from sqlalchemy.orm import joinedload + from app.models import ReportEmailSchedule + from app import db + + # Query with eager loading + query = db.session.query(ReportEmailSchedule).options( + joinedload(ReportEmailSchedule.saved_view) + ) + + if not current_user.is_admin: + query = query.filter_by(created_by=current_user.id) + + schedules = query.order_by(ReportEmailSchedule.next_run_at.asc()).all() + + return jsonify({ + "schedules": [{ + "id": s.id, + "saved_view_id": s.saved_view_id, + "saved_view_name": s.saved_view.name if s.saved_view else "Unknown", + "recipients": s.recipients, + "cadence": s.cadence, + "next_run_at": s.next_run_at.isoformat() if s.next_run_at else None, + "last_run_at": s.last_run_at.isoformat() if s.last_run_at else None, + "active": s.active, + "created_at": s.created_at.isoformat() if s.created_at else None, + } for s in schedules] + }) + + @scheduled_reports_bp.route("/reports/scheduled") @login_required def list_scheduled(): @@ -70,3 +103,169 @@ def delete_scheduled(schedule_id): flash(result["message"], "error") return redirect(url_for("scheduled_reports.list_scheduled")) + + +@scheduled_reports_bp.route("/api/reports/scheduled", methods=["POST"]) +@login_required +def api_create_scheduled(): + """Create scheduled report via API""" + service = ScheduledReportService() + data = request.get_json() + + saved_view_id = data.get("saved_view_id", type=int) + recipients = data.get("recipients", "").strip() + cadence = data.get("cadence", "").strip() + cron = data.get("cron", "").strip() or None + timezone = data.get("timezone", "").strip() or None + + if not saved_view_id or not recipients or not cadence: + return jsonify({"success": False, "error": _("Please fill in all required fields.")}), 400 + + result = service.create_schedule( + saved_view_id=saved_view_id, + recipients=recipients, + cadence=cadence, + created_by=current_user.id, + cron=cron, + timezone=timezone, + ) + + if result["success"]: + return jsonify({ + "success": True, + "schedule": { + "id": result["schedule"].id, + "saved_view_name": result["schedule"].saved_view.name if result["schedule"].saved_view else "Unknown", + "recipients": result["schedule"].recipients, + "cadence": result["schedule"].cadence, + "next_run_at": result["schedule"].next_run_at.isoformat() if result["schedule"].next_run_at else None, + } + }) + else: + return jsonify({"success": False, "error": result["message"]}), 400 + + +@scheduled_reports_bp.route("/api/reports/scheduled//toggle", methods=["POST"]) +@login_required +def api_toggle_scheduled(schedule_id): + """Toggle active status of scheduled report""" + from app import db + schedule = ReportEmailSchedule.query.get_or_404(schedule_id) + + if schedule.created_by != current_user.id and not current_user.is_admin: + return jsonify({"success": False, "error": _("Permission denied")}), 403 + + schedule.active = not schedule.active + db.session.commit() + + return jsonify({"success": True, "active": schedule.active}) + + +@scheduled_reports_bp.route("/api/reports/scheduled/", methods=["DELETE"]) +@login_required +def api_delete_scheduled(schedule_id): + """Delete scheduled report via API""" + service = ScheduledReportService() + result = service.delete_schedule(schedule_id, current_user.id) + + if result["success"]: + return jsonify({"success": True}) + else: + return jsonify({"success": False, "error": result["message"]}), 400 + + +@scheduled_reports_bp.route("/api/reports/saved-views", methods=["GET"]) +@login_required +def api_saved_views(): + """Get saved report views for current user""" + saved_views = SavedReportView.query.filter_by(owner_id=current_user.id).all() + return jsonify({ + "saved_views": [{ + "id": sv.id, + "name": sv.name, + "scope": sv.scope, + } for sv in saved_views] + }) + + +@scheduled_reports_bp.route("/api/reports/scheduled", methods=["POST"]) +@login_required +def api_create_scheduled(): + """Create scheduled report via API""" + service = ScheduledReportService() + data = request.get_json() + + saved_view_id = data.get("saved_view_id", type=int) + recipients = data.get("recipients", "").strip() + cadence = data.get("cadence", "").strip() + cron = data.get("cron", "").strip() or None + timezone = data.get("timezone", "").strip() or None + + if not saved_view_id or not recipients or not cadence: + return jsonify({"success": False, "error": _("Please fill in all required fields.")}), 400 + + result = service.create_schedule( + saved_view_id=saved_view_id, + recipients=recipients, + cadence=cadence, + created_by=current_user.id, + cron=cron, + timezone=timezone, + ) + + if result["success"]: + return jsonify({ + "success": True, + "schedule": { + "id": result["schedule"].id, + "saved_view_name": result["schedule"].saved_view.name if result["schedule"].saved_view else "Unknown", + "recipients": result["schedule"].recipients, + "cadence": result["schedule"].cadence, + "next_run_at": result["schedule"].next_run_at.isoformat() if result["schedule"].next_run_at else None, + } + }) + else: + return jsonify({"success": False, "error": result["message"]}), 400 + + +@scheduled_reports_bp.route("/api/reports/scheduled//toggle", methods=["POST"]) +@login_required +def api_toggle_scheduled(schedule_id): + """Toggle active status of scheduled report""" + from app import db + schedule = ReportEmailSchedule.query.get_or_404(schedule_id) + + if schedule.created_by != current_user.id and not current_user.is_admin: + return jsonify({"success": False, "error": _("Permission denied")}), 403 + + schedule.active = not schedule.active + db.session.commit() + + return jsonify({"success": True, "active": schedule.active}) + + +@scheduled_reports_bp.route("/api/reports/scheduled/", methods=["DELETE"]) +@login_required +def api_delete_scheduled(schedule_id): + """Delete scheduled report via API""" + service = ScheduledReportService() + result = service.delete_schedule(schedule_id, current_user.id) + + if result["success"]: + return jsonify({"success": True}) + else: + return jsonify({"success": False, "error": result["message"]}), 400 + + +@scheduled_reports_bp.route("/api/reports/saved-views", methods=["GET"]) +@login_required +def api_saved_views(): + """Get saved report views for current user""" + saved_views = SavedReportView.query.filter_by(owner_id=current_user.id).all() + return jsonify({ + "saved_views": [{ + "id": sv.id, + "name": sv.name, + "scope": sv.scope, + } for sv in saved_views] + }) diff --git a/app/routes/team_chat.py b/app/routes/team_chat.py index 2cadb21..260d3af 100644 --- a/app/routes/team_chat.py +++ b/app/routes/team_chat.py @@ -74,6 +74,90 @@ def chat_channel(channel_id): return render_template("chat/channel.html", channel=channel, messages=messages, members=members) +@team_chat_bp.route("/chat/channels//send-message", methods=["POST"]) +@login_required +def send_message(channel_id): + """Send a message via form submission (supports attachments)""" + import json + import os + + channel = ChatChannel.query.get_or_404(channel_id) + + # Check membership + membership = ChatChannelMember.query.filter_by( + channel_id=channel_id, + user_id=current_user.id + ).first() + + if not membership and not current_user.is_admin: + flash(_("You don't have access to this channel"), "error") + return redirect(url_for("team_chat.chat_channel", channel_id=channel_id)) + + content = request.form.get("content", "").strip() + attachment_data = request.form.get("attachment_data") + + if not content and not attachment_data: + flash(_("Message cannot be empty"), "error") + return redirect(url_for("team_chat.chat_channel", channel_id=channel_id)) + + # Parse attachment data if provided + attachment_url = None + attachment_filename = None + attachment_size = None + message_type = "text" + + if attachment_data: + try: + attachment_info = json.loads(attachment_data) + attachment_url = attachment_info.get("url") + attachment_filename = attachment_info.get("filename") + attachment_size = attachment_info.get("size") + message_type = "file" + except: + pass + + # Create message + message = ChatMessage( + channel_id=channel_id, + user_id=current_user.id, + message=content or attachment_filename or "", + message_type=message_type, + attachment_url=attachment_url, + attachment_filename=attachment_filename, + attachment_size=attachment_size + ) + + # Parse mentions + mentions = message.parse_mentions() + if mentions: + message.mentions = mentions + + db.session.add(message) + + # Update channel updated_at + channel.updated_at = datetime.utcnow() + + db.session.commit() + + # Notify mentioned users + if mentions: + from app.utils.notification_service import NotificationService + service = NotificationService() + for user_id in mentions: + service.send_notification( + user_id=user_id, + title="You were mentioned", + message=f"{current_user.display_name} mentioned you in {channel.name}", + type="info", + priority="high" + ) + + if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({"success": True, "message": message.to_dict()}) + + return redirect(url_for("team_chat.chat_channel", channel_id=channel_id)) + + @team_chat_bp.route("/api/chat/channels", methods=["GET", "POST"]) @login_required def api_channels(): @@ -147,9 +231,12 @@ def api_messages(channel_id): message = ChatMessage( channel_id=channel_id, user_id=current_user.id, - message=data.get("message"), + message=data.get("message", ""), message_type=data.get("message_type", "text"), - reply_to_id=data.get("reply_to_id") + reply_to_id=data.get("reply_to_id"), + attachment_url=data.get("attachment_url"), + attachment_filename=data.get("attachment_filename"), + attachment_size=data.get("attachment_size") ) # Parse mentions @@ -267,3 +354,113 @@ def api_react(message_id): return jsonify({"success": True, "reactions": reactions}) + +@team_chat_bp.route("/chat/channels//messages//attachments/download") +@login_required +def download_attachment(channel_id, message_id): + """Download an attachment from a chat message""" + from flask import send_file, current_app + import os + + message = ChatMessage.query.get_or_404(message_id) + + # Verify message belongs to channel + if message.channel_id != channel_id: + flash(_("Invalid message"), "error") + return redirect(url_for("team_chat.chat_channel", channel_id=channel_id)) + + # Check membership + membership = ChatChannelMember.query.filter_by( + channel_id=channel_id, + user_id=current_user.id + ).first() + + if not membership and not current_user.is_admin: + flash(_("You don't have access to this channel"), "error") + return redirect(url_for("team_chat.chat_index")) + + if not message.attachment_url: + flash(_("No attachment found"), "error") + return redirect(url_for("team_chat.chat_channel", channel_id=channel_id)) + + # Build file path + file_path = os.path.join(current_app.root_path, "..", message.attachment_url) + + if not os.path.exists(file_path): + flash(_("File not found"), "error") + return redirect(url_for("team_chat.chat_channel", channel_id=channel_id)) + + return send_file( + file_path, + as_attachment=True, + download_name=message.attachment_filename, + ) + + +@team_chat_bp.route("/chat/channels//upload-attachment", methods=["POST"]) +@login_required +def upload_attachment(channel_id): + """Upload an attachment for a chat message""" + from werkzeug.utils import secure_filename + from flask import current_app, jsonify + import os + from datetime import datetime + + channel = ChatChannel.query.get_or_404(channel_id) + + # Check membership + membership = ChatChannelMember.query.filter_by( + channel_id=channel_id, + user_id=current_user.id + ).first() + + if not membership and not current_user.is_admin: + return jsonify({"error": _("You don't have access to this channel")}), 403 + + # File upload configuration + ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf", "doc", "docx", "txt", "xls", "xlsx", "zip", "rar", "csv", "json"} + UPLOAD_FOLDER = "uploads/chat_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: + return jsonify({"error": _("No file provided")}), 400 + + file = request.files["file"] + if file.filename == "": + return jsonify({"error": _("No file selected")}), 400 + + if not allowed_file(file.filename): + return jsonify({"error": _("File type not allowed")}), 400 + + # Check file size + file.seek(0, os.SEEK_END) + file_size = file.tell() + file.seek(0) + + if file_size > MAX_FILE_SIZE: + return jsonify({"error": _("File size exceeds maximum allowed size (10 MB)")}), 400 + + # Save file + original_filename = secure_filename(file.filename) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{channel_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) + + # Return file info for message creation + return jsonify({ + "success": True, + "attachment": { + "url": os.path.join(UPLOAD_FOLDER, filename), + "filename": original_filename, + "size": file_size + } + }) diff --git a/app/routes/timer.py b/app/routes/timer.py index 55a3261..2250a6d 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -2,7 +2,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db, socketio, log_event, track_event -from app.models import User, Project, TimeEntry, Task, Settings, Activity +from app.models import User, Project, TimeEntry, Task, Settings, Activity, Client from app.utils.timezone import parse_local_datetime, utc_to_local from datetime import datetime, timedelta import json @@ -17,6 +17,7 @@ timer_bp = Blueprint("timer", __name__) def start_timer(): """Start a new timer for the current user""" project_id = request.form.get("project_id", type=int) + client_id = request.form.get("client_id", type=int) task_id = request.form.get("task_id", type=int) notes = request.form.get("notes", "").strip() template_id = request.form.get("template_id", type=int) @@ -45,40 +46,63 @@ def start_timer(): template.record_usage() db.session.commit() - if not project_id: - flash(_("Project is required"), "error") - current_app.logger.warning("Start timer failed: missing project_id") + # Require either project or client + if not project_id and not client_id: + flash(_("Either a project or a client is required"), "error") + current_app.logger.warning("Start timer failed: missing project_id and client_id") return redirect(url_for("main.dashboard")) - # Check if project exists - project = Project.query.get(project_id) - if not project: - flash(_("Invalid project selected"), "error") - current_app.logger.warning("Start timer failed: invalid project_id=%s", project_id) - return redirect(url_for("main.dashboard")) + project = None + client = None - # Check if project is active (not archived or inactive) - if project.status == "archived": - flash(_("Cannot start timer for an archived project. Please unarchive the project first."), "error") - current_app.logger.warning("Start timer failed: project_id=%s is archived", project_id) - return redirect(url_for("main.dashboard")) - elif project.status != "active": - flash(_("Cannot start timer for an inactive project"), "error") - current_app.logger.warning("Start timer failed: project_id=%s is not active", project_id) - return redirect(url_for("main.dashboard")) - - # If a task is provided, validate it belongs to the project - if task_id: - task = Task.query.filter_by(id=task_id, project_id=project_id).first() - if not task: - flash(_("Selected task is invalid for the chosen project"), "error") - current_app.logger.warning( - "Start timer failed: task_id=%s does not belong to project_id=%s", task_id, project_id - ) + # Validate project if provided + if project_id: + project = Project.query.get(project_id) + if not project: + flash(_("Invalid project selected"), "error") + current_app.logger.warning("Start timer failed: invalid project_id=%s", project_id) return redirect(url_for("main.dashboard")) + + # Check if project is active (not archived or inactive) + if project.status == "archived": + flash(_("Cannot start timer for an archived project. Please unarchive the project first."), "error") + current_app.logger.warning("Start timer failed: project_id=%s is archived", project_id) + return redirect(url_for("main.dashboard")) + elif project.status != "active": + flash(_("Cannot start timer for an inactive project"), "error") + current_app.logger.warning("Start timer failed: project_id=%s is not active", project_id) + return redirect(url_for("main.dashboard")) + + # If a task is provided, validate it belongs to the project + if task_id: + task = Task.query.filter_by(id=task_id, project_id=project_id).first() + if not task: + flash(_("Selected task is invalid for the chosen project"), "error") + current_app.logger.warning( + "Start timer failed: task_id=%s does not belong to project_id=%s", task_id, project_id + ) + return redirect(url_for("main.dashboard")) + else: + task = None else: task = None + # Validate client if provided (and no project) + if client_id and not project_id: + client = Client.query.filter_by(id=client_id, status="active").first() + if not client: + flash(_("Invalid client selected"), "error") + current_app.logger.warning("Start timer failed: invalid client_id=%s", client_id) + return redirect(url_for("main.dashboard")) + + # Tasks are not allowed for client-only timers + if task_id: + flash(_("Tasks can only be selected for project-based timers"), "error") + current_app.logger.warning( + "Start timer failed: task_id=%s provided for client-only timer (client_id=%s)", task_id, client_id + ) + return redirect(url_for("main.dashboard")) + # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: @@ -91,7 +115,8 @@ def start_timer(): new_timer = TimeEntry( user_id=current_user.id, - project_id=project_id, + project_id=project_id if project_id else None, + client_id=client_id if client_id and not project_id else None, task_id=task.id if task else None, start_time=local_now(), notes=notes if notes else None, @@ -99,21 +124,44 @@ def start_timer(): ) db.session.add(new_timer) - if not safe_commit("start_timer", {"user_id": current_user.id, "project_id": project_id, "task_id": task_id}): + if not safe_commit( + "start_timer", + { + "user_id": current_user.id, + "project_id": project_id, + "client_id": client_id, + "task_id": task_id, + }, + ): flash(_("Could not start timer due to a database error. Please check server logs."), "error") return redirect(url_for("main.dashboard")) current_app.logger.info( - "Started new timer id=%s for user=%s project_id=%s task_id=%s", + "Started new timer id=%s for user=%s project_id=%s client_id=%s task_id=%s", new_timer.id, current_user.username, project_id, + client_id, task_id, ) # Track timer started event - log_event("timer.started", user_id=current_user.id, project_id=project_id, task_id=task_id, description=notes) + log_event( + "timer.started", + user_id=current_user.id, + project_id=project_id, + client_id=client_id, + task_id=task_id, + description=notes, + ) track_event( - current_user.id, "timer.started", {"project_id": project_id, "task_id": task_id, "has_description": bool(notes)} + current_user.id, + "timer.started", + { + "project_id": project_id, + "client_id": client_id, + "task_id": task_id, + "has_description": bool(notes), + }, ) # Log activity @@ -122,9 +170,17 @@ def start_timer(): action="started", entity_type="time_entry", entity_id=new_timer.id, - entity_name=f"{project.name}" + (f" - {task.name}" if task else ""), - description=f"Started timer for {project.name}" + (f" - {task.name}" if task else ""), - extra_data={"project_id": project_id, "task_id": task_id}, + entity_name=( + f"{project.name}" if project else f"{client.name if client else _('Unknown')}" + ) + + (f" - {task.name}" if task else ""), + description=( + f"Started timer for {project.name}" + if project + else f"Started timer for {client.name if client else _('Unknown')}" + ) + + (f" - {task.name}" if task else ""), + extra_data={"project_id": project_id, "client_id": client_id, "task_id": task_id}, ip_address=request.remote_addr, user_agent=request.headers.get("User-Agent"), ) @@ -134,7 +190,13 @@ def start_timer(): if timer_count == 1: # First timer ever track_onboarding_first_timer( - current_user.id, {"project_id": project_id, "has_task": bool(task_id), "has_notes": bool(notes)} + current_user.id, + { + "project_id": project_id, + "client_id": client_id, + "has_task": bool(task_id), + "has_notes": bool(notes), + }, ) # Emit WebSocket event for real-time updates @@ -142,7 +204,8 @@ def start_timer(): payload = { "user_id": current_user.id, "timer_id": new_timer.id, - "project_name": project.name, + "project_name": project.name if project else None, + "client_name": client.name if client else None, "start_time": new_timer.start_time.isoformat(), } if task: @@ -400,7 +463,8 @@ def timer_status(): "active": True, "timer": { "id": active_timer.id, - "project_name": active_timer.project.name, + "project_name": active_timer.project.name if active_timer.project else None, + "client_name": active_timer.client.name if active_timer.client else None, "start_time": active_timer.start_time.isoformat(), "current_duration": active_timer.current_duration_seconds, "duration_formatted": active_timer.duration_formatted, @@ -581,11 +645,16 @@ def delete_timer(timer_id): @login_required def manual_entry(): """Create a manual time entry""" - # Get active projects for dropdown (used for both GET and error re-renders on POST) - active_projects = Project.query.filter_by(status="active").order_by(Project.name).all() + from app.models import Client + from app.services import TimeTrackingService - # Get project_id and task_id from query parameters for pre-filling + # Get active projects and clients for dropdown (used for both GET and error re-renders on POST) + active_projects = Project.query.filter_by(status="active").order_by(Project.name).all() + active_clients = Client.query.filter_by(status="active").order_by(Client.name).all() + + # Get project_id, client_id, and task_id from query parameters for pre-filling project_id = request.args.get("project_id", type=int) + client_id = request.args.get("client_id", type=int) task_id = request.args.get("task_id", type=int) template_id = request.args.get("template", type=int) @@ -610,8 +679,9 @@ def manual_entry(): task_id = template.task_id if request.method == "POST": - project_id = request.form.get("project_id", type=int) - task_id = request.form.get("task_id", type=int) + project_id = request.form.get("project_id", type=int) or None + client_id = request.form.get("client_id", type=int) or None + task_id = request.form.get("task_id", type=int) or None start_date = request.form.get("start_date") start_time = request.form.get("start_time") end_date = request.form.get("end_date") @@ -621,61 +691,31 @@ def manual_entry(): billable = request.form.get("billable") == "on" # Validate required fields - if not all([project_id, start_date, start_time, end_date, end_time]): - flash(_("All fields are required"), "error") + if not all([start_date, start_time, end_date, end_time]): + flash(_("Date and time fields are required"), "error") return render_template( "timer/manual_entry.html", projects=active_projects, + clients=active_clients, selected_project_id=project_id, + selected_client_id=client_id, selected_task_id=task_id, template_data=template_data, ) - # Check if project exists - project = Project.query.get(project_id) - if not project: - flash(_("Invalid project selected"), "error") + # Validate that either project or client is selected + if not project_id and not client_id: + flash(_("Either a project or a client must be selected"), "error") return render_template( "timer/manual_entry.html", projects=active_projects, + clients=active_clients, selected_project_id=project_id, + selected_client_id=client_id, selected_task_id=task_id, template_data=template_data, ) - # Check if project is active (not archived or inactive) - if project.status == "archived": - flash(_("Cannot create time entries for an archived project. Please unarchive the project first."), "error") - return render_template( - "timer/manual_entry.html", - projects=active_projects, - selected_project_id=project_id, - selected_task_id=task_id, - template_data=template_data, - ) - elif project.status != "active": - flash(_("Cannot create time entries for an inactive project"), "error") - return render_template( - "timer/manual_entry.html", - projects=active_projects, - selected_project_id=project_id, - selected_task_id=task_id, - template_data=template_data, - ) - - # Validate task if provided - if task_id: - task = Task.query.filter_by(id=task_id, project_id=project_id).first() - if not task: - flash(_("Invalid task selected"), "error") - return render_template( - "timer/manual_entry.html", - projects=active_projects, - selected_project_id=project_id, - selected_task_id=task_id, - template_data=template_data, - ) - # Parse datetime with timezone awareness try: start_time_parsed = parse_local_datetime(start_date, start_time) @@ -685,59 +725,78 @@ def manual_entry(): return render_template( "timer/manual_entry.html", projects=active_projects, + clients=active_clients, selected_project_id=project_id, + selected_client_id=client_id, selected_task_id=task_id, template_data=template_data, ) # Validate time range if end_time_parsed <= start_time_parsed: - flash("End time must be after start time", "error") + flash(_("End time must be after start time"), "error") return render_template( "timer/manual_entry.html", projects=active_projects, + clients=active_clients, selected_project_id=project_id, + selected_client_id=client_id, selected_task_id=task_id, template_data=template_data, ) - # Create manual entry - entry = TimeEntry( + # Use service to create entry (handles validation) + time_tracking_service = TimeTrackingService() + result = time_tracking_service.create_manual_entry( user_id=current_user.id, project_id=project_id, - task_id=task_id, + client_id=client_id, start_time=start_time_parsed, end_time=end_time_parsed, - notes=notes, - tags=tags, - source="manual", + task_id=task_id, + notes=notes if notes else None, + tags=tags if tags else None, billable=billable, ) - db.session.add(entry) - if not safe_commit("manual_entry", {"user_id": current_user.id, "project_id": project_id, "task_id": task_id}): - flash(_("Could not create manual entry due to a database error. Please check server logs."), "error") + if not result.get("success"): + flash(_(result.get("message", "Could not create manual entry")), "error") return render_template( "timer/manual_entry.html", projects=active_projects, + clients=active_clients, selected_project_id=project_id, + selected_client_id=client_id, selected_task_id=task_id, template_data=template_data, ) - if task_id: - task = Task.query.get(task_id) - task_name = task.name if task else "Unknown Task" - flash(f"Manual entry created for {project.name} - {task_name}", "success") - else: - flash(f"Manual entry created for {project.name}", "success") + entry = result.get("entry") + + # Create success message + if entry: + if entry.project: + target_name = entry.project.name + elif entry.client: + target_name = entry.client.name + else: + target_name = "Unknown" + + if task_id and entry.project: + task = Task.query.get(task_id) + task_name = task.name if task else "Unknown Task" + flash(_("Manual entry created for %(project)s - %(task)s", project=target_name, task=task_name), "success") + else: + flash(_("Manual entry created for %(target)s", target=target_name), "success") return redirect(url_for("main.dashboard")) return render_template( "timer/manual_entry.html", projects=active_projects, + clients=active_clients, selected_project_id=project_id, + selected_client_id=client_id, selected_task_id=task_id, template_data=template_data, ) @@ -1011,8 +1070,9 @@ def timer_page(): """Dedicated timer page with visual progress ring and quick project selection""" active_timer = current_user.active_timer - # Get active projects for dropdown + # Get active projects and clients for dropdown active_projects = Project.query.filter_by(status="active").order_by(Project.name).all() + active_clients = Client.query.filter_by(status="active").order_by(Client.name).all() # Get recent projects (projects used in last 30 days) thirty_days_ago = datetime.utcnow() - timedelta(days=30) @@ -1072,6 +1132,7 @@ def timer_page(): "timer/timer_page.html", active_timer=active_timer, projects=active_projects, + clients=active_clients, recent_projects=recent_projects, tasks=tasks, templates=templates, diff --git a/app/routes/user.py b/app/routes/user.py index 1ac1956..7e7a895 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -114,6 +114,39 @@ def settings(): flash(_("Standard hours per day must be between 0.5 and 24"), "error") return redirect(url_for("user.settings")) + # UI feature flags - Calendar + current_user.ui_show_calendar = "ui_show_calendar" in request.form + + # UI feature flags - Time Tracking + current_user.ui_show_project_templates = "ui_show_project_templates" in request.form + current_user.ui_show_gantt_chart = "ui_show_gantt_chart" in request.form + current_user.ui_show_kanban_board = "ui_show_kanban_board" in request.form + current_user.ui_show_weekly_goals = "ui_show_weekly_goals" in request.form + + # UI feature flags - CRM + current_user.ui_show_quotes = "ui_show_quotes" in request.form + + # UI feature flags - Finance & Expenses + current_user.ui_show_reports = "ui_show_reports" in request.form + current_user.ui_show_report_builder = "ui_show_report_builder" in request.form + current_user.ui_show_scheduled_reports = "ui_show_scheduled_reports" in request.form + current_user.ui_show_invoice_approvals = "ui_show_invoice_approvals" in request.form + current_user.ui_show_payment_gateways = "ui_show_payment_gateways" in request.form + current_user.ui_show_recurring_invoices = "ui_show_recurring_invoices" in request.form + current_user.ui_show_payments = "ui_show_payments" in request.form + current_user.ui_show_mileage = "ui_show_mileage" in request.form + current_user.ui_show_per_diem = "ui_show_per_diem" in request.form + current_user.ui_show_budget_alerts = "ui_show_budget_alerts" in request.form + + # UI feature flags - Inventory + current_user.ui_show_inventory = "ui_show_inventory" in request.form + + # UI feature flags - Analytics + current_user.ui_show_analytics = "ui_show_analytics" in request.form + + # UI feature flags - Tools + current_user.ui_show_tools = "ui_show_tools" in request.form + # Save changes if safe_commit(db.session): # Log activity diff --git a/app/schemas/client_schema.py b/app/schemas/client_schema.py index 73d4397..65d5d95 100644 --- a/app/schemas/client_schema.py +++ b/app/schemas/client_schema.py @@ -17,6 +17,7 @@ class ClientSchema(Schema): address = fields.Str(allow_none=True) default_hourly_rate = fields.Decimal(allow_none=True, places=2) status = fields.Str(validate=validate.OneOf(["active", "inactive", "archived"])) + custom_fields = fields.Dict(allow_none=True) created_at = fields.DateTime(dump_only=True) updated_at = fields.DateTime(dump_only=True) @@ -33,6 +34,7 @@ class ClientCreateSchema(Schema): phone = fields.Str(allow_none=True, validate=validate.Length(max=50)) address = fields.Str(allow_none=True) default_hourly_rate = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal("0"))) + custom_fields = fields.Dict(allow_none=True) class ClientUpdateSchema(Schema): @@ -45,3 +47,4 @@ class ClientUpdateSchema(Schema): address = fields.Str(allow_none=True) default_hourly_rate = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal("0"))) status = fields.Str(allow_none=True, validate=validate.OneOf(["active", "inactive", "archived"])) + custom_fields = fields.Dict(allow_none=True) diff --git a/app/schemas/time_entry_schema.py b/app/schemas/time_entry_schema.py index cb10cb9..eef85d7 100644 --- a/app/schemas/time_entry_schema.py +++ b/app/schemas/time_entry_schema.py @@ -12,7 +12,8 @@ class TimeEntrySchema(Schema): id = fields.Int(dump_only=True) user_id = fields.Int(required=True) - project_id = fields.Int(required=True) + project_id = fields.Int(allow_none=True) + client_id = fields.Int(allow_none=True) task_id = fields.Int(allow_none=True) start_time = fields.DateTime(required=True) end_time = fields.DateTime(allow_none=True) @@ -26,6 +27,7 @@ class TimeEntrySchema(Schema): # Nested fields (when relations are loaded) project = fields.Nested("ProjectSchema", dump_only=True, allow_none=True) + client = fields.Nested("ClientSchema", dump_only=True, allow_none=True) user = fields.Nested("UserSchema", dump_only=True, allow_none=True) task = fields.Nested("TaskSchema", dump_only=True, allow_none=True) @@ -33,7 +35,8 @@ class TimeEntrySchema(Schema): class TimeEntryCreateSchema(Schema): """Schema for creating a time entry""" - project_id = fields.Int(required=True) + project_id = fields.Int(allow_none=True) + client_id = fields.Int(allow_none=True) task_id = fields.Int(allow_none=True) start_time = fields.DateTime(required=True) end_time = fields.DateTime(allow_none=True) @@ -49,11 +52,36 @@ class TimeEntryCreateSchema(Schema): if start_time and value and value <= start_time: raise ValidationError("end_time must be after start_time") + @validates("project_id") + def validate_project_or_client(self, value, **kwargs): + """Validate that either project_id or client_id is provided""" + data = kwargs.get("data", {}) + client_id = data.get("client_id") + if not value and not client_id: + raise ValidationError("Either project_id or client_id must be provided") + + @validates("client_id") + def validate_client_or_project(self, value, **kwargs): + """Validate that either project_id or client_id is provided""" + data = kwargs.get("data", {}) + project_id = data.get("project_id") + if not value and not project_id: + raise ValidationError("Either project_id or client_id must be provided") + + @validates("task_id") + def validate_task_with_project(self, value, **kwargs): + """Validate that task_id is only provided when project_id is set""" + data = kwargs.get("data", {}) + project_id = data.get("project_id") + if value and not project_id: + raise ValidationError("task_id can only be set when project_id is provided") + class TimeEntryUpdateSchema(Schema): """Schema for updating a time entry""" project_id = fields.Int(allow_none=True) + client_id = fields.Int(allow_none=True) task_id = fields.Int(allow_none=True) start_time = fields.DateTime(allow_none=True) end_time = fields.DateTime(allow_none=True) @@ -65,7 +93,7 @@ class TimeEntryUpdateSchema(Schema): class TimerStartSchema(Schema): """Schema for starting a timer""" - project_id = fields.Int(required=True) + project_id = fields.Int(required=True) # Timers are project-only for now task_id = fields.Int(allow_none=True) notes = fields.Str(allow_none=True, validate=validate.Length(max=5000)) template_id = fields.Int(allow_none=True) diff --git a/app/services/client_service.py b/app/services/client_service.py index ed064db..2eb4ecf 100644 --- a/app/services/client_service.py +++ b/app/services/client_service.py @@ -25,6 +25,7 @@ class ClientService: phone: Optional[str] = None, address: Optional[str] = None, default_hourly_rate: Optional[Decimal] = None, + custom_fields: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ Create a new client. @@ -46,6 +47,7 @@ class ClientService: address=address, default_hourly_rate=default_hourly_rate, status="active", + custom_fields=custom_fields, ) if not safe_commit("create_client", {"name": name, "created_by": created_by}): diff --git a/app/services/time_tracking_service.py b/app/services/time_tracking_service.py index 157b631..3d9819b 100644 --- a/app/services/time_tracking_service.py +++ b/app/services/time_tracking_service.py @@ -167,9 +167,10 @@ class TimeTrackingService: def create_manual_entry( self, user_id: int, - project_id: int, - start_time: datetime, - end_time: datetime, + project_id: Optional[int] = None, + client_id: Optional[int] = None, + start_time: datetime = None, + end_time: datetime = None, task_id: Optional[int] = None, notes: Optional[str] = None, tags: Optional[str] = None, @@ -181,25 +182,52 @@ class TimeTrackingService: Returns: dict with 'success', 'message', and 'entry' keys """ - # Validate project - project = self.project_repo.get_by_id(project_id) - if not project: - return {"success": False, "message": "Invalid project", "error": "invalid_project"} + # Validate that either project_id or client_id is provided + if not project_id and not client_id: + return { + "success": False, + "message": "Either project or client must be selected", + "error": "missing_project_or_client", + } + + # Validate project if provided + if project_id: + project = self.project_repo.get_by_id(project_id) + if not project: + return {"success": False, "message": "Invalid project", "error": "invalid_project"} + + # Validate task if provided (only valid when project_id is set) + if task_id: + task = Task.query.filter_by(id=task_id, project_id=project_id).first() + if not task: + return {"success": False, "message": "Invalid task for selected project", "error": "invalid_task"} + + # Validate client if provided + if client_id: + from app.repositories import ClientRepository + + client_repo = ClientRepository() + client = client_repo.get_by_id(client_id) + if not client: + return {"success": False, "message": "Invalid client", "error": "invalid_client"} + + # Task cannot be set when billing directly to client + if task_id: + return { + "success": False, + "message": "Tasks can only be assigned to project-based time entries", + "error": "task_not_allowed", + } # Validate time range if end_time <= start_time: return {"success": False, "message": "End time must be after start time", "error": "invalid_time_range"} - # Validate task if provided - if task_id: - task = Task.query.filter_by(id=task_id, project_id=project_id).first() - if not task: - return {"success": False, "message": "Invalid task for selected project", "error": "invalid_task"} - # Create entry entry = self.time_entry_repo.create_manual_entry( user_id=user_id, project_id=project_id, + client_id=client_id, start_time=start_time, end_time=end_time, task_id=task_id, @@ -208,7 +236,13 @@ class TimeTrackingService: billable=billable, ) - if not safe_commit("create_manual_entry", {"user_id": user_id, "project_id": project_id}): + commit_data = {"user_id": user_id} + if project_id: + commit_data["project_id"] = project_id + if client_id: + commit_data["client_id"] = client_id + + if not safe_commit("create_manual_entry", commit_data): return { "success": False, "message": "Could not create time entry due to a database error", @@ -223,13 +257,19 @@ class TimeTrackingService: limit: Optional[int] = None, offset: int = 0, project_id: Optional[int] = None, + client_id: Optional[int] = None, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, ) -> List[TimeEntry]: """Get time entries for a user with optional filters""" if start_date and end_date: return self.time_entry_repo.get_by_date_range( - start_date=start_date, end_date=end_date, user_id=user_id, project_id=project_id, include_relations=True + start_date=start_date, + end_date=end_date, + user_id=user_id, + project_id=project_id, + client_id=client_id, + include_relations=True ) elif project_id: return self.time_entry_repo.get_by_project( @@ -248,6 +288,7 @@ class TimeTrackingService: user_id: int, is_admin: bool = False, project_id: Optional[int] = None, + client_id: Optional[int] = None, task_id: Optional[int] = None, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, @@ -285,8 +326,30 @@ class TimeTrackingService: if not project: return {"success": False, "message": "Invalid project", "error": "invalid_project"} entry.project_id = project_id + # Clear client_id when setting project_id + entry.client_id = None + + # Handle client_id update + if client_id is not None: + from app.repositories import ClientRepository + + client_repo = ClientRepository() + client = client_repo.get_by_id(client_id) + if not client: + return {"success": False, "message": "Invalid client", "error": "invalid_client"} + entry.client_id = client_id + # Clear project_id and task_id when setting client_id + entry.project_id = None + entry.task_id = None if task_id is not None: + # Task can only be set when project_id is set + if not entry.project_id: + return { + "success": False, + "message": "Task can only be assigned to project-based time entries", + "error": "task_requires_project", + } entry.task_id = task_id if start_time is not None: entry.start_time = start_time diff --git a/app/templates/admin/link_templates/form.html b/app/templates/admin/link_templates/form.html new file mode 100644 index 0000000..543a963 --- /dev/null +++ b/app/templates/admin/link_templates/form.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': _('Admin'), 'url': url_for('admin.admin_dashboard')}, + {'text': _('Link Templates'), 'url': url_for('link_templates.list_link_templates')}, + {'text': _('Edit') if template else _('Create')} +] %} + +{{ page_header( + icon_class='fas fa-link', + title_text=_('Edit Link Template') if template else _('Create Link Template'), + subtitle_text=_('Configure URL template for client custom fields'), + breadcrumbs=breadcrumbs +) }} + +
+
+ + +
+
+ + +

{{ _('A descriptive name for this link template') }}

+
+ +
+ + +
+ +
+ + +

{{ _('URL with {value} placeholder. Example: https://erp.example.com/customer/{value}') }}

+
+ +
+
+ + +

{{ _('The key in the client custom_fields JSON to use') }}

+
+ +
+ + +

{{ _('Font Awesome icon class (e.g., fas fa-link)') }}

+
+
+ +
+
+ + +

{{ _('Lower numbers appear first') }}

+
+ +
+ +

{{ _('Only active templates are shown on client pages') }}

+
+
+
+ +
+ {{ _('Cancel') }} + +
+
+
+{% endblock %} + diff --git a/app/templates/admin/link_templates/list.html b/app/templates/admin/link_templates/list.html new file mode 100644 index 0000000..b12f589 --- /dev/null +++ b/app/templates/admin/link_templates/list.html @@ -0,0 +1,107 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': _('Admin'), 'url': url_for('admin.admin_dashboard')}, + {'text': _('Link Templates')} +] %} + +{{ page_header( + icon_class='fas fa-link', + title_text=_('Link Templates'), + subtitle_text=_('Manage URL templates for client custom fields'), + breadcrumbs=breadcrumbs, + actions_html='' + _('Create Link Template') + '' +) }} + +
+ {% if templates %} +
+ + + + + + + + + + + + + + {% for template in templates %} + + + + + + + + + + {% endfor %} + +
{{ _('Name') }}{{ _('URL Template') }}{{ _('Field Key') }}{{ _('Icon') }}{{ _('Order') }}{{ _('Status') }}{{ _('Actions') }}
+
{{ template.name }}
+ {% if template.description %} +
{{ template.description }}
+ {% endif %} +
+ {{ template.url_template[:50] }}{% if template.url_template|length > 50 %}...{% endif %} + + {{ template.field_key }} + + {% if template.icon %} + + {% else %} + {{ _('None') }} + {% endif %} + {{ template.order }} + {% if template.is_active %} + {{ _('Active') }} + {% else %} + {{ _('Inactive') }} + {% endif %} + +
+ + {{ _('Edit') }} + +
+ + +
+
+
+
+ {% else %} +
+ +

{{ _('No link templates found.') }}

+ + {{ _('Create Link Template') }} + +
+ {% endif %} +
+ +
+

{{ _('How Link Templates Work') }}

+
+

{{ _('Link templates allow you to create quick links to external systems (like ERP systems) using values from client custom fields.') }}

+

{{ _('Example:') }}

+
    +
  • {{ _('Create a custom field called "debtor_number" on a client') }}
  • +
  • {{ _('Create a link template with URL:') }} https://erp.example.com/customer/{value}
  • +
  • {{ _('Set the field key to "debtor_number"') }}
  • +
  • {{ _('When viewing the client, a link will appear that opens the ERP system with the correct customer ID') }}
  • +
+

{{ _('URL Template Format:') }} {{ _('Use {value} as a placeholder for the custom field value.') }}

+
+
+{% endblock %} + diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index 5b0ec7c..4830215 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -76,6 +76,166 @@ + +
+

{{ _('UI Features (System-wide)') }}

+

+ {{ _('These switches control which navigation items are available to users. Users can only toggle features that are enabled here.') }} +

+ +
+ +
+

{{ _('Calendar') }}

+
+ + +
+
+ + +
+

{{ _('Time Tracking') }}

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

{{ _('CRM') }}

+
+ + +
+
+ + +
+

{{ _('Finance & Expenses') }}

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

{{ _('Inventory') }}

+
+ + +
+
+ + +
+

{{ _('Analytics & Tools') }}

+
+
+ + +
+
+ + +
+
+
+
+
+

{{ _('Company Branding') }}

diff --git a/app/templates/approvals/list.html b/app/templates/approvals/list.html index e137306..9e3eb97 100644 --- a/app/templates/approvals/list.html +++ b/app/templates/approvals/list.html @@ -41,6 +41,10 @@

{{ approval.time_entry.project.name }}

+ {% elif approval.time_entry.client %} +

+ {{ approval.time_entry.client.name }} ({{ _('Direct') }}) +

{% endif %}
diff --git a/app/templates/approvals/view.html b/app/templates/approvals/view.html index 91386eb..2a2da94 100644 --- a/app/templates/approvals/view.html +++ b/app/templates/approvals/view.html @@ -39,6 +39,13 @@
+ {% elif approval.time_entry.client %} +
+
{{ _('Client') }}
+
+ {{ approval.time_entry.client.name }} ({{ _('Direct') }}) +
+
{% endif %} {% if approval.time_entry.task %}
diff --git a/app/templates/base.html b/app/templates/base.html index a857454..8ef5687 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -236,6 +236,7 @@ {{ _('Dashboard') }} + {% if current_user.ui_show_calendar %}
  • + {% endif %}
  • + {% if settings.ui_allow_project_templates and current_user.ui_show_project_templates %}
  • {{ _('Project Templates') }}
  • + {% endif %} + {% if settings.ui_allow_gantt_chart and current_user.ui_show_gantt_chart %}
  • {{ _('Gantt Chart') }}
  • + {% endif %}
  • {{ _('Tasks') }}
  • + {% if settings.ui_allow_kanban_board and current_user.ui_show_kanban_board %}
  • {{ _('Kanban Board') }}
  • + {% endif %} + {% if settings.ui_allow_weekly_goals and current_user.ui_show_weekly_goals %}
  • {{ _('Weekly Goals') }}
  • + {% endif %}
  • @@ -324,11 +334,13 @@ {{ _('Clients') }}
  • + {% if settings.ui_allow_quotes and current_user.ui_show_quotes %}
  • {{ _('Quotes') }}
  • + {% endif %}
  • @@ -348,68 +360,89 @@ {% set nav_active_mileage = ep.startswith('mileage.') %} {% set nav_active_perdiem = ep.startswith('per_diem.') and not ep.startswith('per_diem.list_rates') %} {% set nav_active_budget = ep.startswith('budget_alerts.') %} + {% if settings.ui_allow_reports and current_user.ui_show_reports %}
  • {{ _('Reports') }}
  • + {% endif %} + {% if settings.ui_allow_report_builder and current_user.ui_show_report_builder %}
  • {{ _('Report Builder') }}
  • + {% endif %} + {% if settings.ui_allow_scheduled_reports and current_user.ui_show_scheduled_reports %}
  • {{ _('Scheduled Reports') }}
  • + {% endif %}
  • {{ _('Invoices') }}
  • + {% if settings.ui_allow_invoice_approvals and current_user.ui_show_invoice_approvals %}
  • {{ _('Invoice Approvals') }}
  • + {% endif %} + {% if settings.ui_allow_payment_gateways and current_user.ui_show_payment_gateways %}
  • {{ _('Payment Gateways') }}
  • + {% endif %} + {% if settings.ui_allow_recurring_invoices and current_user.ui_show_recurring_invoices %}
  • {{ _('Recurring Invoices') }}
  • + {% endif %} + {% if settings.ui_allow_payments and current_user.ui_show_payments %}
  • {{ _('Payments') }}
  • + {% endif %}
  • {{ _('Expenses') }}
  • + {% if settings.ui_allow_mileage and current_user.ui_show_mileage %}
  • {{ _('Mileage') }}
  • + {% endif %} + {% if settings.ui_allow_per_diem and current_user.ui_show_per_diem %}
  • {{ _('Per Diem') }}
  • + {% endif %} + {% if settings.ui_allow_budget_alerts and current_user.ui_show_budget_alerts %}
  • {{ _('Budget Alerts') }}
  • + {% endif %} + {% if settings.ui_allow_inventory and current_user.ui_show_inventory %}
  • + {% endif %} + {% if settings.ui_allow_analytics and current_user.ui_show_analytics %}
  • {{ _('Analytics') }}
  • + {% endif %} + {% if settings.ui_allow_tools and current_user.ui_show_tools %}
  • + {% endif %} {% if current_user.is_admin or has_any_permission(['view_users', 'manage_settings', 'view_system_info', 'manage_backups']) %}
  • `).join('')} +
  • + `; + + // Position it relative to the emoji button + const emojiBtn = event.target.closest('button'); + const relativeContainer = emojiBtn.closest('.relative'); + relativeContainer.style.position = 'relative'; + relativeContainer.appendChild(picker); + + // Close picker when clicking outside + setTimeout(() => { + document.addEventListener('click', function closePicker(e) { + if (!picker.contains(e.target) && e.target !== emojiBtn && !emojiBtn.contains(e.target)) { + picker.remove(); + document.removeEventListener('click', closePicker); + } + }); + }, 100); } -function showAttachmentModal() { - // TODO: Implement attachment modal - alert('Attachment upload coming soon'); +function insertEmoji(emoji) { + const messageInput = document.getElementById('messageInput'); + const cursorPos = messageInput.selectionStart; + const textBefore = messageInput.value.substring(0, cursorPos); + const textAfter = messageInput.value.substring(cursorPos); + + messageInput.value = textBefore + emoji + textAfter; + messageInput.focus(); + messageInput.setSelectionRange(cursorPos + emoji.length, cursorPos + emoji.length); + + // Remove emoji picker + const picker = document.getElementById('emojiPicker'); + if (picker) picker.remove(); } + +// Attachment modal +let pendingAttachments = []; + +function showAttachmentModal() { + const modal = document.createElement('div'); + modal.id = 'attachmentModal'; + modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/50'; + modal.innerHTML = ` +
    +
    +
    +

    {{ _('Upload Attachment') }}

    + +
    +
    +
    + + +

    {{ _('Maximum file size: 10 MB') }}

    +
    + +
    + + +
    +
    +
    +
    + `; + document.body.appendChild(modal); + + // Preview file name + document.getElementById('attachmentFile').addEventListener('change', function(e) { + const file = e.target.files[0]; + if (file) { + document.getElementById('fileName').textContent = file.name; + document.getElementById('attachmentPreview').classList.remove('hidden'); + } + }); +} + +function closeAttachmentModal() { + const modal = document.getElementById('attachmentModal'); + if (modal) modal.remove(); +} + +function uploadAttachment(event) { + event.preventDefault(); + const form = event.target; + const formData = new FormData(form); + const fileInput = document.getElementById('attachmentFile'); + const file = fileInput.files[0]; + + if (!file) { + alert('{{ _("Please select a file") }}'); + return; + } + + // Check file size (10 MB) + if (file.size > 10 * 1024 * 1024) { + alert('{{ _("File size exceeds 10 MB limit") }}'); + return; + } + + const uploadBtn = form.querySelector('button[type="submit"]'); + const originalText = uploadBtn.innerHTML; + uploadBtn.disabled = true; + uploadBtn.innerHTML = '{{ _("Uploading...") }}'; + + fetch('{{ url_for("team_chat.upload_attachment", channel_id=channel.id) }}', { + method: 'POST', + body: formData, + headers: { + 'X-CSRFToken': '{{ csrf_token() }}' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + pendingAttachments.push(data.attachment); + closeAttachmentModal(); + + // Show attachment indicator + const messageInput = document.getElementById('messageInput'); + const attachmentIndicator = document.createElement('div'); + attachmentIndicator.id = 'attachmentIndicator'; + attachmentIndicator.className = 'mt-2 p-2 bg-blue-100 dark:bg-blue-900 rounded text-sm'; + attachmentIndicator.innerHTML = ` + ${data.attachment.filename} + + `; + + const messageForm = document.getElementById('messageForm'); + if (!document.getElementById('attachmentIndicator')) { + messageForm.parentElement.insertBefore(attachmentIndicator, messageForm); + } + } else { + alert(data.error || '{{ _("Failed to upload attachment") }}'); + uploadBtn.disabled = false; + uploadBtn.innerHTML = originalText; + } + }) + .catch(error => { + console.error('Error:', error); + alert('{{ _("Failed to upload attachment") }}'); + uploadBtn.disabled = false; + uploadBtn.innerHTML = originalText; + }); +} + +function removeAttachment() { + pendingAttachments = []; + const indicator = document.getElementById('attachmentIndicator'); + if (indicator) indicator.remove(); +} + +// Update message form submission to include attachments +const originalSubmit = document.getElementById('messageForm').onsubmit; +document.getElementById('messageForm').addEventListener('submit', function(e) { + e.preventDefault(); + const form = this; + const formData = new FormData(form); + + // Add attachment data if any + if (pendingAttachments.length > 0) { + formData.append('attachment_data', JSON.stringify(pendingAttachments[0])); + formData.append('message_type', 'file'); + } + + fetch(form.action, { + method: 'POST', + body: formData, + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + form.reset(); + pendingAttachments = []; + const indicator = document.getElementById('attachmentIndicator'); + if (indicator) indicator.remove(); + location.reload(); + } else { + alert(data.error || 'Failed to send message'); + } + }) + .catch(error => { + console.error('Error:', error); + alert('Failed to send message'); + }); +}); {% endblock %} diff --git a/app/templates/client_portal/time_entries.html b/app/templates/client_portal/time_entries.html index dc0c78a..f11868f 100644 --- a/app/templates/client_portal/time_entries.html +++ b/app/templates/client_portal/time_entries.html @@ -61,7 +61,15 @@ {% for entry in time_entries %} {{ entry.start_time.strftime('%Y-%m-%d') }} - {{ entry.project.name if entry.project else _('N/A') }} + + {% if entry.project %} + {{ entry.project.name }} + {% elif entry.client %} + {{ entry.client.name }} ({{ _('Direct') }}) + {% else %} + {{ _('N/A') }} + {% endif %} + {{ entry.user.display_name if entry.user else _('N/A') }} {{ entry.start_time.strftime('%H:%M') }} {{ entry.end_time.strftime('%H:%M') if entry.end_time else '-' }} diff --git a/app/templates/clients/create.html b/app/templates/clients/create.html index 89450e3..62dbb7b 100644 --- a/app/templates/clients/create.html +++ b/app/templates/clients/create.html @@ -75,6 +75,18 @@ +
    +

    {{ _('Custom Fields') }}

    +

    + {{ _('Add custom fields to store additional information like debtor numbers, ERP IDs, or any other data you need.') }} +

    +
    +
    + +
    +
    {{ _('Cancel') }} @@ -120,6 +132,23 @@ diff --git a/app/templates/reports/index.html b/app/templates/reports/index.html index 336f9c9..e0de937 100644 --- a/app/templates/reports/index.html +++ b/app/templates/reports/index.html @@ -336,13 +336,226 @@ function hideScheduledReportsModal() { } function loadScheduledReports() { - // This would fetch scheduled reports from the backend - // For now, just show empty state + const listContainer = document.getElementById('scheduledReportsList'); + listContainer.innerHTML = '

    Loading...

    '; + + fetch('/api/reports/scheduled') + .then(response => response.json()) + .then(data => { + if (data.schedules && data.schedules.length > 0) { + listContainer.innerHTML = data.schedules.map(schedule => { + const nextRun = schedule.next_run_at ? new Date(schedule.next_run_at).toLocaleString() : 'Not scheduled'; + const lastRun = schedule.last_run_at ? new Date(schedule.last_run_at).toLocaleString() : 'Never'; + const statusBadge = schedule.active + ? 'Active' + : 'Inactive'; + + return ` +
    +
    +
    +

    ${escapeHtml(schedule.saved_view_name || 'Unknown Report')}

    +

    + ${schedule.cadence} + + ${schedule.recipients.split(',').length} recipient(s) +

    +
    + ${statusBadge} +
    +
    +
    Next run: ${nextRun}
    +
    Last run: ${lastRun}
    +
    +
    + + +
    +
    + `; + }).join(''); + } else { + listContainer.innerHTML = '

    No scheduled reports yet.

    '; + } + }) + .catch(error => { + console.error('Error loading scheduled reports:', error); + listContainer.innerHTML = '

    Failed to load scheduled reports.

    '; + }); } function showAddScheduledReportForm() { - // This would show a form to add a new scheduled report - alert('Scheduled report creation feature will be implemented in the backend'); + // Load saved views first + fetch('/api/reports/saved-views') + .then(response => response.json()) + .then(data => { + const savedViews = data.saved_views || []; + if (savedViews.length === 0) { + alert('{{ _("You need to create a saved report view first. Please create one from the reports page.") }}'); + return; + } + + // Create form modal + const formModal = document.createElement('div'); + formModal.id = 'addScheduledReportForm'; + formModal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/50'; + formModal.innerHTML = ` +
    +
    +
    +

    {{ _('Schedule Report') }}

    + +
    +
    +
    + + +
    +
    + + +

    {{ _('Comma-separated email addresses') }}

    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + `; + document.body.appendChild(formModal); + }) + .catch(error => { + console.error('Error loading saved views:', error); + alert('{{ _("Failed to load report views") }}'); + }); +} + +function closeAddScheduledReportForm() { + const formModal = document.getElementById('addScheduledReportForm'); + if (formModal) formModal.remove(); +} + +function createScheduledReport(event) { + event.preventDefault(); + const form = event.target; + const formData = { + saved_view_id: parseInt(document.getElementById('savedViewId').value), + recipients: document.getElementById('recipients').value.trim(), + cadence: document.getElementById('cadence').value, + }; + + const submitBtn = form.querySelector('button[type="submit"]'); + const originalText = submitBtn.innerHTML; + submitBtn.disabled = true; + submitBtn.innerHTML = '{{ _("Creating...") }}'; + + fetch('/api/reports/scheduled', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': '{{ csrf_token() }}' + }, + body: JSON.stringify(formData) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + closeAddScheduledReportForm(); + loadScheduledReports(); + if (window.showToast) { + window.showToast('{{ _("Scheduled report created successfully") }}', 'success'); + } + } else { + alert(data.error || '{{ _("Failed to create scheduled report") }}'); + submitBtn.disabled = false; + submitBtn.innerHTML = originalText; + } + }) + .catch(error => { + console.error('Error:', error); + alert('{{ _("Failed to create scheduled report") }}'); + submitBtn.disabled = false; + submitBtn.innerHTML = originalText; + }); +} + +function toggleSchedule(scheduleId, newActive) { + fetch(`/api/reports/scheduled/${scheduleId}/toggle`, { + method: 'POST', + headers: { + 'X-CSRFToken': '{{ csrf_token() }}' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + loadScheduledReports(); + } else { + alert(data.error || '{{ _("Failed to update schedule") }}'); + } + }) + .catch(error => { + console.error('Error:', error); + alert('{{ _("Failed to update schedule") }}'); + }); +} + +function deleteSchedule(scheduleId) { + if (!confirm('{{ _("Are you sure you want to delete this scheduled report?") }}')) { + return; + } + + fetch(`/api/reports/scheduled/${scheduleId}`, { + method: 'DELETE', + headers: { + 'X-CSRFToken': '{{ csrf_token() }}' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + loadScheduledReports(); + if (window.showToast) { + window.showToast('{{ _("Scheduled report deleted successfully") }}', 'success'); + } + } else { + alert(data.error || '{{ _("Failed to delete scheduled report") }}'); + } + }) + .catch(error => { + console.error('Error:', error); + alert('{{ _("Failed to delete scheduled report") }}'); + }); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; } // Initialize date inputs diff --git a/app/templates/reports/summary.html b/app/templates/reports/summary.html index a8771ce..05ef65e 100644 --- a/app/templates/reports/summary.html +++ b/app/templates/reports/summary.html @@ -4,6 +4,10 @@ {% block content %}

    {{ _('Summary Report') }}

    + + {{ _('Export to Excel') }} +
    diff --git a/app/templates/reports/task_report.html b/app/templates/reports/task_report.html index 04a56a5..6945cb1 100644 --- a/app/templates/reports/task_report.html +++ b/app/templates/reports/task_report.html @@ -47,6 +47,14 @@
    + + +
    diff --git a/app/templates/reports/user_report.html b/app/templates/reports/user_report.html index 9775eeb..a62d7dc 100644 --- a/app/templates/reports/user_report.html +++ b/app/templates/reports/user_report.html @@ -47,6 +47,14 @@
    + + +
    diff --git a/app/templates/timer/manual_entry.html b/app/templates/timer/manual_entry.html index c4451b1..8e605ff 100644 --- a/app/templates/timer/manual_entry.html +++ b/app/templates/timer/manual_entry.html @@ -21,7 +21,12 @@

    - {{ _('Duplicating entry:') }} {{ original_entry.project.name }}{% if original_entry.task %} - {{ original_entry.task.name }}{% endif %} + {{ _('Duplicating entry:') }} + {% if original_entry.project %} + {{ original_entry.project.name }}{% if original_entry.task %} - {{ original_entry.task.name }}{% endif %} + {% elif original_entry.client %} + {{ original_entry.client.name }} ({{ _('Direct') }}) + {% endif %}

    {{ _('Original:') }} {{ original_entry.start_time|user_datetime('%Y-%m-%d %H:%M') }} {{ _('to') }} {{ original_entry.end_time|user_datetime('%Y-%m-%d %H:%M') if original_entry.end_time else _('N/A') }} ({{ original_entry.duration_formatted }}) @@ -32,19 +37,31 @@ {% endif %}

    -
    +
    - + {% for project in projects %} {% endfor %}
    +
    + + +

    {{ _('Select either a project or a client') }}

    +
    +
    +
    -
    - - +
    +
    + + +
    +
    + + +

    + {{ _('Select either a project or a client') }} +

    +
    @@ -219,32 +241,61 @@ {% block scripts_extra %}