diff --git a/app/__init__.py b/app/__init__.py index 74e4a888..c0a9644e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -459,9 +459,9 @@ def apply_security_headers(response): csp = ( "default-src 'self'; " "img-src 'self' data: https:; " - "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.datatables.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.datatables.net https://uicdn.toast.com; " "font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com data:; " - "script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh; " + "script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://uicdn.toast.com; " "connect-src 'self' ws: wss:; " "frame-ancestors 'none'" ) diff --git a/app/routes/clients.py b/app/routes/clients.py index c8747130..c40dc859 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify from flask_babel import gettext as _ from flask_login import login_required, current_user import app as app_module @@ -42,7 +42,22 @@ def list_clients(): @login_required def create_client(): """Create a new client""" + # Detect AJAX/JSON request + try: + wants_json = ( + request.headers.get('X-Requested-With') == 'XMLHttpRequest' + or request.is_json + or request.accept_mimetypes['application/json'] >= request.accept_mimetypes['text/html'] + ) + except Exception: + wants_json = False + if not current_user.is_admin: + if wants_json: + return jsonify({ + 'error': 'forbidden', + 'message': _('Only administrators can create clients') + }), 403 flash(_('Only administrators can create clients'), 'error') return redirect(url_for('clients.list_clients')) @@ -66,6 +81,8 @@ def create_client(): # Validate required fields if not name: + if wants_json: + return jsonify({'error': 'validation_error', 'messages': ['Client name is required']}), 400 flash('Client name is required', 'error') try: current_app.logger.warning("Validation failed: missing client name") @@ -75,6 +92,8 @@ def create_client(): # Check if client name already exists if Client.query.filter_by(name=name).first(): + if wants_json: + return jsonify({'error': 'validation_error', 'messages': ['A client with this name already exists']}), 400 flash('A client with this name already exists', 'error') try: current_app.logger.warning("Validation failed: duplicate client name '%s'", name) @@ -86,6 +105,8 @@ def create_client(): try: default_hourly_rate = Decimal(default_hourly_rate) if default_hourly_rate else None except ValueError: + if wants_json: + return jsonify({'error': 'validation_error', 'messages': ['Invalid hourly rate format']}), 400 flash('Invalid hourly rate format', 'error') try: current_app.logger.warning("Validation failed: invalid hourly rate '%s'", default_hourly_rate) @@ -106,6 +127,8 @@ def create_client(): db.session.add(client) if not safe_commit('create_client', {'name': name}): + if wants_json: + return jsonify({'error': 'db_error', 'message': 'Could not create client due to a database error.'}), 500 flash('Could not create client due to a database error. Please check server logs.', 'error') return render_template('clients/create.html') @@ -113,6 +136,13 @@ def create_client(): app_module.log_event("client.created", user_id=current_user.id, client_id=client.id) app_module.track_event(current_user.id, "client.created", {"client_id": client.id}) + if wants_json: + return jsonify({ + 'id': client.id, + 'name': client.name, + 'default_hourly_rate': float(client.default_hourly_rate) if client.default_hourly_rate is not None else None + }), 201 + flash(f'Client "{name}" created successfully', 'success') return redirect(url_for('clients.view_client', client_id=client.id)) diff --git a/app/routes/tasks.py b/app/routes/tasks.py index d3f4c252..afd948af 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -214,6 +214,7 @@ def edit_task(task_id): # Preload context for potential validation errors projects = Project.query.filter_by(status='active').order_by(Project.name).all() users = User.query.order_by(User.username).all() + project_id = request.form.get('project_id', type=int) name = request.form.get('name', '').strip() description = request.form.get('description', '').strip() priority = request.form.get('priority', 'medium') @@ -225,6 +226,15 @@ def edit_task(task_id): if not name: flash('Task name is required', 'error') return render_template('tasks/edit.html', task=task, projects=projects, users=users) + + # Validate project selection + if not project_id: + flash('Project is required', 'error') + return render_template('tasks/edit.html', task=task, projects=projects, users=users) + new_project = Project.query.filter_by(id=project_id, status='active').first() + if not new_project: + flash('Selected project does not exist or is inactive', 'error') + return render_template('tasks/edit.html', task=task, projects=projects, users=users) # Parse estimated hours try: @@ -243,6 +253,19 @@ def edit_task(task_id): return render_template('tasks/edit.html', task=task, projects=projects, users=users) # Update task + # Handle project change first so any early returns (status flows) still persist it + if project_id != task.project_id: + old_project_id = task.project_id + task.project_id = project_id + # Keep related time entries consistent with the task's project + try: + for entry in task.time_entries.all(): + entry.project_id = project_id + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='project_change', details=f"Project changed from {old_project_id} to {project_id}")) + except Exception: + # If anything goes wrong here, fall back to just changing the task + pass + task.name = name task.description = description task.priority = priority diff --git a/app/static/enhanced-ui.css b/app/static/enhanced-ui.css index ac77abb5..16e9f749 100644 --- a/app/static/enhanced-ui.css +++ b/app/static/enhanced-ui.css @@ -639,3 +639,87 @@ top: 0; } +/* ============================================ + Markdown content styling (fallback to match app) + Applies when Tailwind Typography is not available + ============================================ */ +.prose, .prose-sm { + color: #2D3748; + line-height: 1.7; +} +.dark .prose, .dark .prose-sm { color: #E2E8F0; } +.prose h1, .prose h2, .prose h3, .prose-sm h1, .prose-sm h2, .prose-sm h3 { + color: inherit; + font-weight: 700; + margin: 0.75rem 0 0.5rem; +} +.prose h4, .prose h5, .prose h6, .prose-sm h4, .prose-sm h5, .prose-sm h6 { + color: inherit; + font-weight: 600; + margin: 0.75rem 0 0.5rem; +} +.prose p, .prose-sm p { margin: 0.5rem 0; } +.prose a, .prose-sm a { color: #3B82F6; text-decoration: underline; } +.dark .prose a, .dark .prose-sm a { color: #60A5FA; } +.prose ul, .prose ol, .prose-sm ul, .prose-sm ol { padding-left: 1.25rem; margin: 0.5rem 0; } +.prose li, .prose-sm li { margin: 0.25rem 0; } +.prose code, .prose-sm code { + background: #F7F9FB; + color: #1F2937; + padding: 0.1rem 0.3rem; + border-radius: 4px; +} +.dark .prose code, .dark .prose-sm code { background: #1F2937; color: #E5E7EB; } +.prose pre, .prose-sm pre { + background: #0B1220; + color: #E5E7EB; + padding: 0.75rem 1rem; + border-radius: 8px; + overflow-x: auto; +} +.dark .prose pre, .dark .prose-sm pre { background: #0B1220; } +.prose blockquote, .prose-sm blockquote { + border-left: 4px solid #3B82F6; + padding-left: 0.75rem; + margin: 0.75rem 0; + color: #475569; +} +.dark .prose blockquote, .dark .prose-sm blockquote { color: #94A3B8; } +.prose table, .prose-sm table { width: 100%; border-collapse: collapse; } +.prose table th, .prose table td, .prose-sm table th, .prose-sm table td { + border: 1px solid #E2E8F0; padding: 0.5rem 0.75rem; +} +.dark .prose table th, .dark .prose table td, .dark .prose-sm table th, .dark .prose-sm table td { border-color: #4A5568; } +.prose img, .prose-sm img { max-width: 100%; border-radius: 8px; } + +/* ============================================ + Toast UI Editor – theme bridging to match app + ============================================ */ +.toastui-editor-defaultUI { + background: #FFFFFF; + border: 1px solid #E2E8F0; + border-radius: 8px; +} +.dark .toastui-editor-defaultUI { + background: #2D3748; + border-color: #4A5568; +} +.toastui-editor-defaultUI .toastui-editor-toolbar { + background: transparent; + border-bottom-color: #E2E8F0; +} +.dark .toastui-editor-defaultUI .toastui-editor-toolbar { border-bottom-color: #4A5568; } +.toastui-editor-defaultUI .ProseMirror, +.toastui-editor-contents { + color: #2D3748; +} +.dark .toastui-editor-defaultUI .ProseMirror, +.dark .toastui-editor-contents { + color: #E2E8F0; +} +.toastui-editor-contents a { color: #3B82F6; } +.dark .toastui-editor-contents a { color: #60A5FA; } +.toastui-editor-contents pre { background: #0B1220; color: #E5E7EB; border-radius: 8px; } +.toastui-editor-contents code { background: #F7F9FB; color: #1F2937; padding: 0.1rem 0.3rem; border-radius: 4px; } +.dark .toastui-editor-contents code { background: #1F2937; color: #E5E7EB; } + diff --git a/app/templates/clients/view.html b/app/templates/clients/view.html index 677449de..6f70e298 100644 --- a/app/templates/clients/view.html +++ b/app/templates/clients/view.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "components/ui.html" import confirm_dialog %} {% block content %}
Client details and associated projects.
{{ project.client.name }}
{{ project.description or 'No description provided.' }}
+{{ _('Optional: Add context, requirements, or specific instructions for the task') }}
@@ -149,7 +149,7 @@Task details and history.
{{ task.description }}
++ +
{{ _('Optional: Add context, objectives, or specific requirements for the project') }}