diff --git a/.editorconfig b/.editorconfig index f2cee7e..096689d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -26,7 +26,7 @@ indent_size = 2 # CSS, JavaScript, and JSON files [*.{css,scss,js,json}] -indent_size = 2 +indent_size = 4 # Markdown files [*.md] diff --git a/.prettierrc b/.prettierrc index e2094a6..a17d342 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,30 +1,26 @@ { - "arrowParens": "always", - "bracketSpacing": true, - "embeddedLanguageFormatting": "auto", - "htmlWhitespaceSensitivity": "css", - "insertPragma": false, - "jsxSingleQuote": false, - "printWidth": 100, - "proseWrap": "preserve", - "quoteProps": "as-needed", - "requirePragma": false, - "semi": true, - "singleQuote": false, - "tabWidth": 2, - "trailingComma": "es5", - "useTabs": false, - "overrides": [ - { - "files": [ - "*.html" - ], - "options": { - "parser": "jinja-template" - } - } - ], - "plugins": [ - "prettier-plugin-jinja-template" - ] + "arrowParens": "always", + "bracketSpacing": true, + "embeddedLanguageFormatting": "auto", + "htmlWhitespaceSensitivity": "css", + "insertPragma": false, + "jsxSingleQuote": false, + "printWidth": 100, + "proseWrap": "preserve", + "quoteProps": "as-needed", + "requirePragma": false, + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false, + "overrides": [ + { + "files": ["*.html"], + "options": { + "parser": "jinja-template" + } + } + ], + "plugins": ["prettier-plugin-jinja-template"] } diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md index 7bae7e4..5a4028c 100644 --- a/IMPLEMENTATION_SUMMARY.md +++ b/IMPLEMENTATION_SUMMARY.md @@ -3,16 +3,19 @@ ## Core Features Implemented 1. **Multi-Tenant Architecture**: + - Companies have isolated data and user access - Users belong to specific companies - Role-based permissions (admin, company admin, regular user) 2. **Data Management**: + - CSV file upload and processing - Data source management - Chat session records with comprehensive metadata 3. **Dashboard Visualization**: + - Interactive charts using Plotly.js - Key metrics and KPIs - Time-series analysis @@ -21,18 +24,21 @@ - Category distribution 4. **Search and Analysis**: + - Full-text search across chat sessions - Filtering by various attributes - Detailed view of individual chat sessions - Transcript viewing 5. **User Management**: + - User registration and authentication - Profile management - Password change functionality - Role assignment 6. **Admin Interface**: + - Company management - User administration - Data source oversight @@ -67,6 +73,7 @@ ### Data Flow 1. **Upload Process**: + - File validation - CSV parsing - Data normalization @@ -74,6 +81,7 @@ - Association with company 2. **Dashboard Generation**: + - Data aggregation - Statistical calculations - Chart data preparation diff --git a/PRETTIER_SETUP.md b/PRETTIER_SETUP.md index 89fa15f..3e47249 100644 --- a/PRETTIER_SETUP.md +++ b/PRETTIER_SETUP.md @@ -55,10 +55,7 @@ If you need to prevent Prettier from formatting a section of your template: ```html {# prettier-ignore #} -
- This section will not be formatted - by Prettier. -
+
This section will not be formatted by Prettier.
diff --git a/PROJECT_OVERVIEW.md b/PROJECT_OVERVIEW.md index f7692d7..274c061 100644 --- a/PROJECT_OVERVIEW.md +++ b/PROJECT_OVERVIEW.md @@ -70,10 +70,12 @@ This will create: ## Usage Flow 1. **Admin Setup**: + - Admin creates companies - Admin creates users and assigns them to companies 2. **Company Admin**: + - Uploads CSV files with chat data - Creates and configures dashboards - Manages company users diff --git a/QUICK_START_GUIDE.md b/QUICK_START_GUIDE.md index 5033aa9..d62a4a0 100644 --- a/QUICK_START_GUIDE.md +++ b/QUICK_START_GUIDE.md @@ -92,10 +92,12 @@ This will create: ### Admin Tasks 1. **Access Admin Panel**: + - Go to - Login with your admin credentials 2. **Create a Company**: + - Go to Companies > Add Company - Fill in the company details and save @@ -108,10 +110,12 @@ This will create: ### Company Admin Tasks 1. **Login to Dashboard**: + - Go to - Login with your company admin credentials 2. **Upload Chat Data**: + - Click on "Upload Data" in the sidebar - Fill in the data source details - Select a CSV file containing chat data @@ -126,11 +130,13 @@ This will create: ### Regular User Tasks 1. **View Dashboard**: + - Login with your user credentials - The dashboard will show automatically - Select different dashboards from the sidebar 2. **Search Chat Sessions**: + - Click on "Search" in the top navigation - Enter search terms - Use filters to refine results @@ -143,25 +149,25 @@ This will create: Your CSV files should include the following columns: -| Column | Description | Type | -| ----------------- | ------------------------------- | -------- | -| session_id | Unique ID for the chat | String | -| start_time | Session start time | Datetime | -| end_time | Session end time | Datetime | -| ip_address | User's IP address | String | -| country | User's country | String | -| language | Chat language | String | -| messages_sent | Number of messages | Integer | -| sentiment | Sentiment analysis result | String | -| escalated | Whether chat was escalated | Boolean | -| forwarded_hr | Whether chat was sent to HR | Boolean | -| full_transcript | Complete chat text | Text | -| avg_response_time | Average response time (seconds) | Float | -| tokens | Number of tokens used | Integer | -| tokens_eur | Cost in EUR | Float | -| category | Chat category | String | -| initial_msg | First user message | Text | -| user_rating | User satisfaction rating | String | +| Column | Description | Type | +| ------------------- | ------------------------------- | -------- | +| `session_id` | Unique ID for the chat | String | +| `start_time` | Session start time | Datetime | +| `end_time` | Session end time | Datetime | +| `ip_address` | User's IP address | String | +| `country` | User's country | String | +| `language` | Chat language | String | +| `messages_sent` | Number of messages | Integer | +| `sentiment` | Sentiment analysis result | String | +| `escalated` | Whether chat was escalated | Boolean | +| `forwarded_hr` | Whether chat was sent to HR | Boolean | +| `full_transcript` | Complete chat text | Text | +| `avg_response_time` | Average response time (seconds) | Float | +| `tokens` | Number of tokens used | Integer | +| `tokens_eur` | Cost in EUR | Float | +| `category` | Chat category | String | +| `initial_msg` | First user message | Text | +| `user_rating` | User satisfaction rating | String | Example CSV row: diff --git a/README.md b/README.md index 29057eb..a9f5e9e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ A Django application that creates an analytics dashboard for chat session data. 2. Create a virtual environment and activate it: - ```sh + ```sh uv venv source .venv/bin/activate # On Windows: .venv\Scripts\activate ``` @@ -99,23 +99,25 @@ A Django application that creates an analytics dashboard for chat session data. The CSV file should contain the following columns: -- session_id: Unique identifier for the chat session -- start_time: When the session started (datetime) -- end_time: When the session ended (datetime) -- ip_address: IP address of the user -- country: Country of the user -- language: Language used in the conversation -- messages_sent: Number of messages in the conversation (integer) -- sentiment: Sentiment analysis of the conversation (string) -- escalated: Whether the conversation was escalated (boolean) -- forwarded_hr: Whether the conversation was forwarded to HR (boolean) -- full_transcript: Full transcript of the conversation (text) -- avg_response_time: Average response time in seconds (float) -- tokens: Total number of tokens used (integer) -- tokens_eur: Cost of tokens in EUR (float) -- category: Category of the conversation (string) -- initial_msg: First message from the user (text) -- user_rating: User rating of the conversation (string) +| Column | Description | +| ------------------- | ------------------------------------------------------ | +| `session_id` | Unique identifier for the chat session | +| `start_time` | When the session started (datetime) | +| `end_time` | When the session ended (datetime) | +| `ip_address` | IP address of the user | +| `country` | Country of the user | +| `language` | Language used in the conversation | +| `messages_sent` | Number of messages in the conversation (integer) | +| `sentiment` | Sentiment analysis of the conversation (string) | +| `escalated` | Whether the conversation was escalated (boolean) | +| `forwarded_hr` | Whether the conversation was forwarded to HR (boolean) | +| `full_transcript` | Full transcript of the conversation (text) | +| `avg_response_time` | Average response time in seconds (float) | +| `tokens` | Total number of tokens used (integer) | +| `tokens_eur` | Cost of tokens in EUR (float) | +| `category` | Category of the conversation (string) | +| `initial_msg` | First message from the user (text) | +| `user_rating` | User rating of the conversation (string) | ## Future Enhancements @@ -128,4 +130,4 @@ The CSV file should contain the following columns: ## License -This project is licensed under the MIT License - see the LICENSE file for details. +This project is unlicensed. Usage is restricted to personal and educational purposes only. For commercial use, please contact the author. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..a85dde3 --- /dev/null +++ b/TODO.md @@ -0,0 +1,13 @@ +# TODO List + +- When I zoom into the dasboard page, the graphs don't scale/adjust to fit the window until I completely refresh the page, can we solve that? +- Add export to CSV and PDF functionality to the dashboard. +- Add a button to download the CSV file for the selected company. +- Make it possible to modify the column names in the CSV file through the admin interface. +- Add possibility to add a company logo in the admin interface. +- Add periodic download from https://proto.notso.ai/jumbo/chats possibility for the jumbo company. + - Authentication: Basic Auth + - URL: https://proto.notso.ai/jumbo/chats + - Username: jumboadmin + - Password: jumboadmin + diff --git a/dashboard_project/dashboard/views.py b/dashboard_project/dashboard/views.py index 74b55bc..53c3ae0 100644 --- a/dashboard_project/dashboard/views.py +++ b/dashboard_project/dashboard/views.py @@ -9,6 +9,7 @@ from django.core.paginator import Paginator from django.db.models import Avg, Q from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render +from django.template.loader import render_to_string from django.utils import timezone from .forms import DashboardForm, DataSourceUploadForm @@ -16,6 +17,11 @@ from .models import ChatSession, Dashboard, DataSource from .utils import generate_dashboard_data, process_csv_file +def is_ajax_navigation(request): + """Check if this is an AJAX navigation request""" + return request.headers.get("X-AJAX-Navigation") == "true" + + @login_required def dashboard_view(request): """Main dashboard view""" @@ -57,23 +63,27 @@ def dashboard_view(request): # Generate dashboard data dashboard_data = generate_dashboard_data(selected_dashboard.data_sources.all()) - # Convert dashboard data to JSON for use in JavaScript - dashboard_data_json = json.dumps( - { - "sentiment_data": dashboard_data["sentiment_data"], - "country_data": dashboard_data["country_data"], - "category_data": dashboard_data["category_data"], - "time_series_data": dashboard_data["time_series_data"], - } - ) + # Convert each component of dashboard data to JSON + sentiment_data_json = json.dumps(dashboard_data["sentiment_data"]) + country_data_json = json.dumps(dashboard_data["country_data"]) + category_data_json = json.dumps(dashboard_data["category_data"]) + time_series_data_json = json.dumps(dashboard_data["time_series_data"]) context = { "dashboards": dashboards, "selected_dashboard": selected_dashboard, "dashboard_data": dashboard_data, - "dashboard_data_json": dashboard_data_json, + "sentiment_data_json": sentiment_data_json, + "country_data_json": country_data_json, + "category_data_json": category_data_json, + "time_series_data_json": time_series_data_json, } + # Check if this is an AJAX navigation request + if is_ajax_navigation(request): + html_content = render_to_string("dashboard/dashboard.html", context, request=request) + return JsonResponse({"html": html_content, "title": "Dashboard | Chat Analytics"}) + return render(request, "dashboard/dashboard.html", context) @@ -124,6 +134,11 @@ def upload_data_view(request): "data_sources": data_sources, } + # Check if this is an AJAX navigation request + if is_ajax_navigation(request): + html_content = render_to_string("dashboard/upload.html", context, request=request) + return JsonResponse({"html": html_content, "title": "Upload Data | Chat Analytics"}) + return render(request, "dashboard/upload.html", context) @@ -155,6 +170,11 @@ def data_source_detail_view(request, data_source_id): "page_obj": page_obj, } + # Check if this is an AJAX navigation request + if is_ajax_navigation(request): + html_content = render_to_string("dashboard/data_source_detail.html", context, request=request) + return JsonResponse({"html": html_content, "title": f"{data_source.name} | Chat Analytics"}) + return render(request, "dashboard/data_source_detail.html", context) @@ -177,6 +197,11 @@ def chat_session_detail_view(request, session_id): "session": chat_session, } + # Check if this is an AJAX navigation request + if is_ajax_navigation(request): + html_content = render_to_string("dashboard/chat_session_detail.html", context, request=request) + return JsonResponse({"html": html_content, "title": f"Chat Session {session_id} | Chat Analytics"}) + return render(request, "dashboard/chat_session_detail.html", context) @@ -209,6 +234,11 @@ def create_dashboard_view(request): "is_create": True, } + # Check if this is an AJAX navigation request + if is_ajax_navigation(request): + html_content = render_to_string("dashboard/dashboard_form.html", context, request=request) + return JsonResponse({"html": html_content, "title": "Create Dashboard | Chat Analytics"}) + return render(request, "dashboard/dashboard_form.html", context) @@ -244,6 +274,11 @@ def edit_dashboard_view(request, dashboard_id): "is_create": False, } + # Check if this is an AJAX navigation request + if is_ajax_navigation(request): + html_content = render_to_string("dashboard/dashboard_form.html", context, request=request) + return JsonResponse({"html": html_content, "title": f"Edit Dashboard: {dashboard.name} | Chat Analytics"}) + return render(request, "dashboard/dashboard_form.html", context) @@ -313,8 +348,33 @@ def dashboard_data_api(request, dashboard_id): if not company: return JsonResponse({"error": "User not associated with a company"}, status=403) + # Get time range filter if provided + time_range = request.GET.get("time_range", "all") + dashboard = get_object_or_404(Dashboard, id=dashboard_id, company=company) - dashboard_data = generate_dashboard_data(dashboard.data_sources.all()) + + # Get data sources for this dashboard + data_sources = dashboard.data_sources.all() + + # Apply time filter if needed + filtered_data_sources = data_sources + if time_range and time_range != "all": + # This is a placeholder comment - implement time filtering in a real app + # You would filter ChatSessions based on time_range here + pass + + # Generate the dashboard data + dashboard_data = generate_dashboard_data(filtered_data_sources) + + # Ensure values are JSON serializable + for key in ["sentiment_data", "country_data", "category_data"]: + dashboard_data[key] = list(dashboard_data[key]) + + # Format time series data for proper date serialization + if "time_series_data" in dashboard_data: + for item in dashboard_data["time_series_data"]: + if "date" in item and not isinstance(item["date"], str): + item["date"] = item["date"].strftime("%Y-%m-%d") return JsonResponse(dashboard_data) @@ -373,6 +433,34 @@ def search_chat_sessions(request): "data_source": data_source, } + # Check if this is an AJAX navigation request + if is_ajax_navigation(request): + html_content = render_to_string("dashboard/search_results.html", context, request=request) + return JsonResponse({"html": html_content, "title": "Search Chat Sessions | Chat Analytics"}) + + # Check if this is an AJAX pagination request + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse( + { + "status": "success", + "html_data": render(request, "dashboard/partials/search_results_table.html", context).content.decode( + "utf-8" + ), + "page_obj": { + "number": page_obj.number, + "has_previous": page_obj.has_previous(), + "has_next": page_obj.has_next(), + "previous_page_number": page_obj.previous_page_number() if page_obj.has_previous() else None, + "next_page_number": page_obj.next_page_number() if page_obj.has_next() else None, + "paginator": { + "num_pages": page_obj.paginator.num_pages, + "count": page_obj.paginator.count, + }, + }, + "query": query, + } + ) + return render(request, "dashboard/search_results.html", context) @@ -449,4 +537,33 @@ def data_view(request): "escalation_rate": escalation_rate, } + # Check if this is an AJAX navigation request + if is_ajax_navigation(request): + html_content = render_to_string("dashboard/data_view.html", context, request=request) + return JsonResponse({"html": html_content, "title": "Data View | Chat Analytics"}) + + # Check if this is an AJAX pagination request + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse( + { + "status": "success", + "html_data": render(request, "dashboard/partials/data_table.html", context).content.decode("utf-8"), + "page_obj": { + "number": page_obj.number, + "has_previous": page_obj.has_previous(), + "has_next": page_obj.has_next(), + "previous_page_number": page_obj.previous_page_number() if page_obj.has_previous() else None, + "next_page_number": page_obj.next_page_number() if page_obj.has_next() else None, + "paginator": { + "num_pages": page_obj.paginator.num_pages, + "count": page_obj.paginator.count, + }, + }, + "view": view, + "avg_response_time": avg_response_time, + "avg_messages": avg_messages, + "escalation_rate": escalation_rate, + } + ) + return render(request, "dashboard/data_view.html", context) diff --git a/dashboard_project/static/css/dashboard.css b/dashboard_project/static/css/dashboard.css index e7d52ea..2f4b788 100644 --- a/dashboard_project/static/css/dashboard.css +++ b/dashboard_project/static/css/dashboard.css @@ -260,3 +260,21 @@ font-size: 1.5rem; } } + +/* --- Stat Boxes Alignment Fix (Bottom Align, No Overlap) --- */ +.stats-row { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + align-items: stretch; +} +.stats-card { + flex: 1 1 0; + min-width: 200px; + display: flex; + flex-direction: column; + justify-content: flex-end; /* Push content to bottom */ + align-items: flex-start; + box-sizing: border-box; + /* Remove min-height/height for natural stretch */ +} diff --git a/dashboard_project/static/js/ajax-navigation.js b/dashboard_project/static/js/ajax-navigation.js new file mode 100644 index 0000000..9a7d57a --- /dev/null +++ b/dashboard_project/static/js/ajax-navigation.js @@ -0,0 +1,273 @@ +/** + * ajax-navigation.js - JavaScript for AJAX-based navigation across the entire application + * + * This script handles AJAX navigation between pages in the Chat Analytics Dashboard. + * It intercepts link clicks, loads content via AJAX, and updates the browser history. + */ + +document.addEventListener("DOMContentLoaded", function () { + // Only initialize if AJAX navigation is enabled + if (typeof ENABLE_AJAX_NAVIGATION !== "undefined" && ENABLE_AJAX_NAVIGATION) { + setupAjaxNavigation(); + } + + // Function to set up AJAX navigation for the application + function setupAjaxNavigation() { + // Configuration + const config = { + mainContentSelector: "#main-content", // Selector for the main content area + navLinkSelector: ".ajax-nav-link", // Selector for links to handle with AJAX + loadingIndicatorId: "nav-loading-indicator", // ID of the loading indicator + excludePatterns: [ + // URL patterns to exclude from AJAX navigation + /\.(pdf|xlsx?|docx?|csv|zip|png|jpe?g|gif|svg)$/i, // File downloads + /\/admin\//, // Admin pages + /\/accounts\/logout\//, // Logout page + /\/api\//, // API endpoints + ], + }; + + // Create and insert the loading indicator + if (!document.getElementById(config.loadingIndicatorId)) { + const loadingIndicator = document.createElement("div"); + loadingIndicator.id = config.loadingIndicatorId; + loadingIndicator.className = "position-fixed top-0 start-0 end-0"; + loadingIndicator.innerHTML = + '
'; + loadingIndicator.style.display = "none"; + loadingIndicator.style.zIndex = "9999"; + document.body.appendChild(loadingIndicator); + } + + // Get the loading indicator element + const loadingIndicator = document.getElementById(config.loadingIndicatorId); + + // Get the main content container + const mainContent = document.querySelector(config.mainContentSelector); + if (!mainContent) { + console.warn("Main content container not found. AJAX navigation disabled."); + return; + } + + // Function to check if a URL should be excluded from AJAX navigation + function shouldExcludeUrl(url) { + for (const pattern of config.excludePatterns) { + if (pattern.test(url)) { + return true; + } + } + return false; + } + + // Function to show the loading indicator + function showLoading() { + loadingIndicator.style.display = "block"; + } + + // Function to hide the loading indicator + function hideLoading() { + loadingIndicator.style.display = "none"; + } + + // Function to handle AJAX page navigation + function handlePageNavigation(url, pushState = true) { + if (shouldExcludeUrl(url)) { + window.location.href = url; + return; + } + showLoading(); + const currentScrollPos = window.scrollY; + fetch(url, { + headers: { + "X-Requested-With": "XMLHttpRequest", + "X-AJAX-Navigation": "true", + Accept: "text/html", + }, + }) + .then((response) => { + if (!response.ok) throw new Error(`Network response was not ok: ${response.status}`); + return response.text(); + }) + .then((html) => { + // Parse the HTML and extract #main-content + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = html; + const newContent = tempDiv.querySelector(config.mainContentSelector); + if (!newContent) throw new Error("Could not find main content in the response"); + mainContent.innerHTML = newContent.innerHTML; + // Update the page title + const titleMatch = html.match(/(.*?)<\/title>/i); + if (titleMatch) document.title = titleMatch[1]; + // Re-initialize dynamic content + reloadScripts(mainContent); + attachEventListeners(); + initializePageScripts(); + if (pushState) { + history.pushState( + { url: url, title: document.title, scrollPos: currentScrollPos }, + document.title, + url + ); + window.scrollTo({ top: 0, behavior: "smooth" }); + } else if (window.history.state && window.history.state.scrollPos) { + window.scrollTo({ top: window.history.state.scrollPos }); + } + hideLoading(); + }) + .catch((error) => { + console.error("Error during AJAX navigation:", error); + hideLoading(); + window.location.href = url; + }); + } + + // Function to reload and execute scripts in new content + function reloadScripts(container) { + const scripts = container.getElementsByTagName("script"); + for (let script of scripts) { + const newScript = document.createElement("script"); + + // Copy all attributes + Array.from(script.attributes).forEach((attr) => { + newScript.setAttribute(attr.name, attr.value); + }); + + // Copy inline script content + newScript.textContent = script.textContent; + + // Replace old script with new one + script.parentNode.replaceChild(newScript, script); + } + } + + // Function to handle form submissions + function handleFormSubmission(form, e) { + e.preventDefault(); + + // Show loading indicator + showLoading(); + + // Get form data + const formData = new FormData(form); + const method = form.method.toLowerCase(); + const url = form.action || window.location.href; + + // Configure fetch options + const fetchOptions = { + method: method, + headers: { + "X-AJAX-Navigation": "true", + }, + }; + + // Handle different HTTP methods + if (method === "get") { + const queryParams = new URLSearchParams(formData).toString(); + handlePageNavigation(url + (queryParams ? "?" + queryParams : "")); + } else { + fetchOptions.body = formData; + + fetch(url, fetchOptions) + .then((response) => { + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + return response.json(); + }) + .then((data) => { + if (data.redirect) { + // Handle server-side redirects + handlePageNavigation(data.redirect, true); + } else { + // Update page content + mainContent.innerHTML = data.html; + document.title = data.title || document.title; + + // Re-initialize dynamic content + reloadScripts(mainContent); + attachEventListeners(); + initializePageScripts(); + + // Update URL if needed + if (data.url) { + history.pushState({ url: data.url }, document.title, data.url); + } + } + }) + .catch((error) => { + console.error("Form submission error:", error); + // Fallback to traditional form submission + form.submit(); + }) + .finally(() => { + hideLoading(); + }); + } + } + + // Function to initialize scripts needed for the new page content + function initializePageScripts() { + // Re-initialize any custom scripts that might be needed + if (typeof setupAjaxPagination === "function") { + setupAjaxPagination(); + } + + // Initialize Bootstrap tooltips, popovers, etc. + if (typeof bootstrap !== "undefined") { + // Initialize tooltips + const tooltipTriggerList = [].slice.call( + document.querySelectorAll('[data-bs-toggle="tooltip"]') + ); + tooltipTriggerList.map(function (tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl); + }); + + // Initialize popovers + const popoverTriggerList = [].slice.call( + document.querySelectorAll('[data-bs-toggle="popover"]') + ); + popoverTriggerList.map(function (popoverTriggerEl) { + return new bootstrap.Popover(popoverTriggerEl); + }); + } + } + + // Function to attach event listeners to forms and links + function attachEventListeners() { + // Handle AJAX navigation links + document.querySelectorAll(config.navLinkSelector).forEach((link) => { + if (!link.dataset.ajaxNavInitialized) { + link.addEventListener("click", function (e) { + if (e.ctrlKey || e.metaKey || e.shiftKey || shouldExcludeUrl(this.href)) { + return; // Let the browser handle these cases + } + e.preventDefault(); + handlePageNavigation(this.href); + }); + link.dataset.ajaxNavInitialized = "true"; + } + }); + + // Handle forms with AJAX + document + .querySelectorAll("form.ajax-form, form.search-form, form.filter-form") + .forEach((form) => { + if (!form.dataset.ajaxFormInitialized) { + form.addEventListener("submit", (e) => handleFormSubmission(form, e)); + form.dataset.ajaxFormInitialized = "true"; + } + }); + } + + // Initial attachment of event listeners + attachEventListeners(); + + // Handle browser back/forward buttons + window.addEventListener("popstate", function (event) { + if (event.state && event.state.url) { + handlePageNavigation(event.state.url, false); + } else { + // Fallback to current URL if no state + handlePageNavigation(window.location.href, false); + } + }); + } +}); diff --git a/dashboard_project/static/js/ajax-pagination.js b/dashboard_project/static/js/ajax-pagination.js new file mode 100644 index 0000000..bcec9a7 --- /dev/null +++ b/dashboard_project/static/js/ajax-pagination.js @@ -0,0 +1,106 @@ +/** + * ajax-pagination.js - Common JavaScript for AJAX pagination across the application + * + * This script handles AJAX-based pagination for all pages in the Chat Analytics Dashboard. + * It intercepts pagination link clicks, loads content via AJAX, and updates the browser history. + */ + +document.addEventListener("DOMContentLoaded", function () { + // Initialize AJAX pagination + setupAjaxPagination(); + + // Function to set up AJAX pagination for the entire application + function setupAjaxPagination() { + // Configuration - can be customized per page if needed + const config = { + contentContainerId: "ajax-content-container", // ID of the container to update + loadingSpinnerId: "ajax-loading-spinner", // ID of the loading spinner + paginationLinkClass: "pagination-link", // Class for pagination links + retryMessage: "An error occurred while loading data. Please try again.", + }; + + // Get container elements + const contentContainer = document.getElementById(config.contentContainerId); + const loadingSpinner = document.getElementById(config.loadingSpinnerId); + + // Exit if the page doesn't have the required elements + if (!contentContainer || !loadingSpinner) return; + + // Function to handle pagination clicks + function setupPaginationListeners() { + document.querySelectorAll("." + config.paginationLinkClass).forEach((link) => { + link.addEventListener("click", function (e) { + e.preventDefault(); + handleAjaxNavigation(this.href); + + // Get the page number if available + const page = this.getAttribute("data-page"); + + // Update browser URL without refreshing + const newUrl = this.href; + history.pushState({ url: newUrl, page: page }, "", newUrl); + }); + }); + } + + // Function to handle AJAX navigation + function handleAjaxNavigation(url) { + // Show loading spinner + contentContainer.classList.add("d-none"); + loadingSpinner.classList.remove("d-none"); + + // Fetch data via AJAX + fetch(url, { + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error(`Network response was not ok: ${response.status}`); + } + return response.json(); + }) + .then((data) => { + if (data.status === "success") { + // Update the content + contentContainer.innerHTML = data.html_data; + + // Re-attach event listeners to new pagination links + setupPaginationListeners(); + + // Update any summary data if present and the page provides it + if (typeof updateSummary === "function" && data.summary) { + updateSummary(data); + } + + // Hide loading spinner, show content + loadingSpinner.classList.add("d-none"); + contentContainer.classList.remove("d-none"); + + // Scroll to top of the content container + contentContainer.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }) + .catch((error) => { + console.error("Error fetching data:", error); + loadingSpinner.classList.add("d-none"); + contentContainer.classList.remove("d-none"); + alert(config.retryMessage); + }); + } + + // Initial setup of event listeners + setupPaginationListeners(); + + // Handle browser back/forward buttons + window.addEventListener("popstate", function (event) { + if (event.state && event.state.url) { + handleAjaxNavigation(event.state.url); + } else { + // If no state, fetch current URL + handleAjaxNavigation(window.location.href); + } + }); + } +}); diff --git a/dashboard_project/static/js/dashboard.js b/dashboard_project/static/js/dashboard.js index ffadc90..fe0d29c 100644 --- a/dashboard_project/static/js/dashboard.js +++ b/dashboard_project/static/js/dashboard.js @@ -55,8 +55,14 @@ document.addEventListener("DOMContentLoaded", function () { document.querySelector("main").appendChild(loadingOverlay); fetch(`/dashboard/api/dashboard/${dashboardId}/data/?time_range=${timeRange || "all"}`) - .then((response) => response.json()) + .then((response) => { + if (!response.ok) { + throw new Error(`Network response was not ok: ${response.status}`); + } + return response.json(); + }) .then((data) => { + console.log("Dashboard API response:", data); updateDashboardStats(data); updateDashboardCharts(data); @@ -115,40 +121,59 @@ document.addEventListener("DOMContentLoaded", function () { // Function to update dashboard charts function updateDashboardCharts(data) { + // Check if Plotly is available + if (!window.Plotly) { + console.error("Plotly library not loaded!"); + document.querySelectorAll(".chart-container").forEach((container) => { + container.innerHTML = + '<div class="text-center py-5"><p class="text-danger">Chart library not available. Please refresh the page.</p></div>'; + }); + return; + } + // Update sessions over time chart const timeSeriesData = data.time_series_data; - if (timeSeriesData && timeSeriesData.length > 0 && window.Plotly) { - const timeSeriesX = timeSeriesData.map((item) => item.date); - const timeSeriesY = timeSeriesData.map((item) => item.count); + if (timeSeriesData && timeSeriesData.length > 0) { + try { + const timeSeriesX = timeSeriesData.map((item) => item.date); + const timeSeriesY = timeSeriesData.map((item) => item.count); - Plotly.react( - "sessions-time-chart", - [ + Plotly.react( + "sessions-time-chart", + [ + { + x: timeSeriesX, + y: timeSeriesY, + type: "scatter", + mode: "lines+markers", + line: { + color: "rgb(75, 192, 192)", + width: 2, + }, + marker: { + color: "rgb(75, 192, 192)", + size: 6, + }, + }, + ], { - x: timeSeriesX, - y: timeSeriesY, - type: "scatter", - mode: "lines+markers", - line: { - color: "rgb(75, 192, 192)", - width: 2, + margin: { t: 10, r: 10, b: 40, l: 40 }, + xaxis: { + title: "Date", }, - marker: { - color: "rgb(75, 192, 192)", - size: 6, + yaxis: { + title: "Number of Sessions", }, - }, - ], - { - margin: { t: 10, r: 10, b: 40, l: 40 }, - xaxis: { - title: "Date", - }, - yaxis: { - title: "Number of Sessions", - }, - } - ); + } + ); + } catch (error) { + console.error("Error rendering time series chart:", error); + document.getElementById("sessions-time-chart").innerHTML = + '<div class="text-center py-5"><p class="text-danger">Error rendering chart.</p></div>'; + } + } else { + document.getElementById("sessions-time-chart").innerHTML = + '<div class="text-center py-5"><p class="text-muted">No time series data available</p></div>'; } // Update sentiment chart diff --git a/dashboard_project/templates/base.html b/dashboard_project/templates/base.html index a498201..7fae940 100644 --- a/dashboard_project/templates/base.html +++ b/dashboard_project/templates/base.html @@ -49,21 +49,21 @@ <ul class="navbar-nav me-auto mb-2 mb-md-0"> <li class="nav-item"> <a - class="nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}" + class="nav-link ajax-nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}" href="{% url 'dashboard' %}" >Dashboard</a > </li> <li class="nav-item"> <a - class="nav-link {% if request.resolver_match.url_name == 'upload_data' %}active{% endif %}" + class="nav-link ajax-nav-link {% if request.resolver_match.url_name == 'upload_data' %}active{% endif %}" href="{% url 'upload_data' %}" >Upload Data</a > </li> <li class="nav-item"> <a - class="nav-link {% if request.resolver_match.url_name == 'search_chat_sessions' %}active{% endif %}" + class="nav-link ajax-nav-link {% if request.resolver_match.url_name == 'search_chat_sessions' %}active{% endif %}" href="{% url 'search_chat_sessions' %}" >Search</a > @@ -86,7 +86,9 @@ {{ user.username }} </button> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown"> - <li><a class="dropdown-item" href="{% url 'profile' %}">Profile</a></li> + <li> + <a class="dropdown-item ajax-nav-link" href="{% url 'profile' %}">Profile</a> + </li> {% if user.is_staff %} <li><a class="dropdown-item" href="{% url 'admin:index' %}">Admin</a></li> {% endif %} @@ -117,7 +119,7 @@ <ul class="nav flex-column"> <li class="nav-item"> <a - class="nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}" + class="nav-link ajax-nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}" href="{% url 'dashboard' %}" > <i class="fas fa-tachometer-alt me-2"></i> @@ -126,7 +128,7 @@ </li> <li class="nav-item"> <a - class="nav-link {% if request.resolver_match.url_name == 'upload_data' %}active{% endif %}" + class="nav-link ajax-nav-link {% if request.resolver_match.url_name == 'upload_data' %}active{% endif %}" href="{% url 'upload_data' %}" > <i class="fas fa-upload me-2"></i> @@ -135,7 +137,7 @@ </li> <li class="nav-item"> <a - class="nav-link {% if request.resolver_match.url_name == 'search_chat_sessions' %}active{% endif %}" + class="nav-link ajax-nav-link {% if request.resolver_match.url_name == 'search_chat_sessions' %}active{% endif %}" href="{% url 'search_chat_sessions' %}" > <i class="fas fa-search me-2"></i> @@ -144,7 +146,7 @@ </li> <li class="nav-item"> <a - class="nav-link {% if request.resolver_match.url_name == 'data_view' %}active{% endif %}" + class="nav-link ajax-nav-link {% if request.resolver_match.url_name == 'data_view' %}active{% endif %}" href="{% url 'data_view' %}" > <i class="fas fa-table me-2"></i> @@ -202,23 +204,54 @@ {# </div> #} {# {% endif %} #} - {% block content %} - {% endblock %} + <div id="main-content"> + {% block content %} + {% endblock %} + </div> </main> </div> </div> <footer> <div class="container"> - <p>© {% now "Y" %} Chat Analytics Dashboard. All rights reserved.</p> + <p>© {% now "Y" %} KJANAT All rights reserved. | Chat Analytics Dashboard.</p> </div> </footer> <!-- Bootstrap JS --> - <script src="https://cdn.jsdelivr.net/npm/bootstrap@latest/dist/js/bootstrap.bundle.min.js"></script> - + <script + src="https://cdn.jsdelivr.net/npm/bootstrap@latest/dist/js/bootstrap.bundle.min.js" + crossorigin="anonymous" + ></script> <!-- jQuery (for Ajax) --> - <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script> + <script + src="https://cdn.jsdelivr.net/npm/jquery@latest/dist/jquery.min.js" + crossorigin="anonymous" + ></script> + + <!-- Custom JavaScript --> + <script src="{% static 'js/main.js' %}"></script> + <script src="{% static 'js/ajax-pagination.js' %}"></script> + <script src="{% static 'js/ajax-navigation.js' %}"></script> + + <!-- Enable AJAX Navigation --> + <script> + // Enable AJAX navigation for the entire application + var ENABLE_AJAX_NAVIGATION = true; + </script> + + <!-- Check if Plotly loaded successfully --> + <script> + if (typeof Plotly === "undefined") { + console.error("Plotly library failed to load. Will attempt to load fallback."); + // Try to load Plotly from alternative source + const script = document.createElement("script"); + script.src = "https://cdn.jsdelivr.net/npm/plotly.js@latest/dist/plotly.min.js"; + script.async = true; + script.crossOrigin = "anonymous"; + document.head.appendChild(script); + } + </script> {% block extra_js %} {{ block.super }} diff --git a/dashboard_project/templates/dashboard/dashboard.html b/dashboard_project/templates/dashboard/dashboard.html index f140fc2..cabbdfd 100644 --- a/dashboard_project/templates/dashboard/dashboard.html +++ b/dashboard_project/templates/dashboard/dashboard.html @@ -147,139 +147,161 @@ {% endblock %} {% block extra_js %} + <!-- prettier-ignore-start --> + <!-- Store the JSON data in script tags to avoid parsing issues --> + <script type="application/json" id="time-series-data">{{ time_series_data_json|safe }}</script> + <script type="application/json" id="sentiment-data">{{ sentiment_data_json|safe }}</script> + <script type="application/json" id="country-data">{{ country_data_json|safe }}</script> + <script type="application/json" id="category-data">{{ category_data_json|safe }}</script> + <!-- prettier-ignore-end --> + <script> document.addEventListener("DOMContentLoaded", function () { - // Parse the dashboard data from JSON - const dashboardData = JSON.parse("{{ dashboard_data_json|safe }}"); + try { + // Parse the dashboard data components from script tags + const timeSeriesData = JSON.parse(document.getElementById("time-series-data").textContent); + const sentimentData = JSON.parse(document.getElementById("sentiment-data").textContent); + const countryData = JSON.parse(document.getElementById("country-data").textContent); + const categoryData = JSON.parse(document.getElementById("category-data").textContent); - // Sessions over time chart - const timeSeriesData = dashboardData.time_series_data; - const timeSeriesX = timeSeriesData.map((item) => item.date); - const timeSeriesY = timeSeriesData.map((item) => item.count); + console.log("Time series data loaded:", timeSeriesData); + console.log("Sentiment data loaded:", sentimentData); + console.log("Country data loaded:", countryData); + console.log("Category data loaded:", categoryData); - Plotly.newPlot( - "sessions-time-chart", - [ - { - x: timeSeriesX, - y: timeSeriesY, - type: "scatter", - mode: "lines+markers", - line: { - color: "rgb(75, 192, 192)", - width: 2, - }, - marker: { - color: "rgb(75, 192, 192)", - size: 6, - }, - }, - ], - { - margin: { t: 10, r: 10, b: 40, l: 40 }, - xaxis: { - title: "Date", - }, - yaxis: { - title: "Number of Sessions", - }, + // Sessions over time chart + if (timeSeriesData && timeSeriesData.length > 0) { + const timeSeriesX = timeSeriesData.map((item) => item.date); + const timeSeriesY = timeSeriesData.map((item) => item.count); + + Plotly.newPlot( + "sessions-time-chart", + [ + { + x: timeSeriesX, + y: timeSeriesY, + type: "scatter", + mode: "lines+markers", + line: { + color: "rgb(75, 192, 192)", + width: 2, + }, + marker: { + color: "rgb(75, 192, 192)", + size: 6, + }, + }, + ], + { + margin: { t: 10, r: 10, b: 40, l: 40 }, + xaxis: { + title: "Date", + }, + yaxis: { + title: "Number of Sessions", + }, + } + ); + } else { + document.getElementById("sessions-time-chart").innerHTML = + '<div class="text-center py-5"><p class="text-muted">No time series data available</p></div>'; } - ); - // Sentiment analysis chart - const sentimentData = dashboardData.sentiment_data; + // Sentiment analysis chart + if (sentimentData && sentimentData.length > 0) { + const sentimentLabels = sentimentData.map((item) => item.sentiment); + const sentimentValues = sentimentData.map((item) => item.count); + const sentimentColors = sentimentLabels.map((sentiment) => { + if (sentiment.toLowerCase().includes("positive")) return "rgb(75, 192, 92)"; + if (sentiment.toLowerCase().includes("negative")) return "rgb(255, 99, 132)"; + if (sentiment.toLowerCase().includes("neutral")) return "rgb(255, 205, 86)"; + return "rgb(201, 203, 207)"; + }); - if (sentimentData.length > 0) { - const sentimentLabels = sentimentData.map((item) => item.sentiment); - const sentimentValues = sentimentData.map((item) => item.count); - const sentimentColors = sentimentLabels.map((sentiment) => { - if (sentiment.toLowerCase().includes("positive")) return "rgb(75, 192, 92)"; - if (sentiment.toLowerCase().includes("negative")) return "rgb(255, 99, 132)"; - if (sentiment.toLowerCase().includes("neutral")) return "rgb(255, 205, 86)"; - return "rgb(201, 203, 207)"; + Plotly.newPlot( + "sentiment-chart", + [ + { + values: sentimentValues, + labels: sentimentLabels, + type: "pie", + marker: { + colors: sentimentColors, + }, + hole: 0.4, + textinfo: "label+percent", + insidetextorientation: "radial", + }, + ], + { + margin: { t: 10, r: 10, b: 10, l: 10 }, + } + ); + } else { + document.getElementById("sentiment-chart").innerHTML = + '<div class="text-center py-5"><p class="text-muted">No sentiment data available</p></div>'; + } + + // Country chart + if (countryData && countryData.length > 0) { + const countryLabels = countryData.map((item) => item.country); + const countryValues = countryData.map((item) => item.count); + + Plotly.newPlot( + "country-chart", + [ + { + x: countryValues, + y: countryLabels, + type: "bar", + orientation: "h", + marker: { + color: "rgb(54, 162, 235)", + }, + }, + ], + { + margin: { t: 10, r: 10, b: 40, l: 100 }, + xaxis: { + title: "Number of Sessions", + }, + } + ); + } else { + document.getElementById("country-chart").innerHTML = + '<div class="text-center py-5"><p class="text-muted">No country data available</p></div>'; + } + + // Category chart + if (categoryData && categoryData.length > 0) { + const categoryLabels = categoryData.map((item) => item.category); + const categoryValues = categoryData.map((item) => item.count); + + Plotly.newPlot( + "category-chart", + [ + { + labels: categoryLabels, + values: categoryValues, + type: "pie", + textinfo: "label+percent", + insidetextorientation: "radial", + }, + ], + { + margin: { t: 10, r: 10, b: 10, l: 10 }, + } + ); + } else { + document.getElementById("category-chart").innerHTML = + '<div class="text-center py-5"><p class="text-muted">No category data available</p></div>'; + } + } catch (error) { + console.error("Error rendering charts:", error); + document.querySelectorAll(".chart-container").forEach((container) => { + container.innerHTML = + '<div class="text-center py-5"><p class="text-danger">Error loading chart data. Please refresh the page.</p></div>'; }); - - Plotly.newPlot( - "sentiment-chart", - [ - { - values: sentimentValues, - labels: sentimentLabels, - type: "pie", - marker: { - colors: sentimentColors, - }, - hole: 0.4, - textinfo: "label+percent", - insidetextorientation: "radial", - }, - ], - { - margin: { t: 10, r: 10, b: 10, l: 10 }, - } - ); - } else { - document.getElementById("sentiment-chart").innerHTML = - '<div class="text-center py-5"><p class="text-muted">No sentiment data available</p></div>'; - } - - // Country chart - const countryData = dashboardData.country_data; - - if (countryData.length > 0) { - const countryLabels = countryData.map((item) => item.country); - const countryValues = countryData.map((item) => item.count); - - Plotly.newPlot( - "country-chart", - [ - { - x: countryValues, - y: countryLabels, - type: "bar", - orientation: "h", - marker: { - color: "rgb(54, 162, 235)", - }, - }, - ], - { - margin: { t: 10, r: 10, b: 40, l: 100 }, - xaxis: { - title: "Number of Sessions", - }, - } - ); - } else { - document.getElementById("country-chart").innerHTML = - '<div class="text-center py-5"><p class="text-muted">No country data available</p></div>'; - } - - // Category chart - const categoryData = dashboardData.category_data; - - if (categoryData.length > 0) { - const categoryLabels = categoryData.map((item) => item.category); - const categoryValues = categoryData.map((item) => item.count); - - Plotly.newPlot( - "category-chart", - [ - { - labels: categoryLabels, - values: categoryValues, - type: "pie", - textinfo: "label+percent", - insidetextorientation: "radial", - }, - ], - { - margin: { t: 10, r: 10, b: 10, l: 10 }, - } - ); - } else { - document.getElementById("category-chart").innerHTML = - '<div class="text-center py-5"><p class="text-muted">No category data available</p></div>'; } }); </script> diff --git a/dashboard_project/templates/dashboard/data_source_detail.html b/dashboard_project/templates/dashboard/data_source_detail.html index d60d439..b004492 100644 --- a/dashboard_project/templates/dashboard/data_source_detail.html +++ b/dashboard_project/templates/dashboard/data_source_detail.html @@ -115,12 +115,18 @@ <td>{{ session.tokens }}</td> <td>{{ session.category|default:"N/A" }}</td> <td> - <a - href="{% url 'chat_session_detail' session.session_id %}" - class="btn btn-sm btn-outline-primary" - > - <i class="fas fa-eye"></i> - </a> + {% if session.session_id %} + <a + href="{% url 'chat_session_detail' session.session_id %}" + class="btn btn-sm btn-outline-primary" + > + <i class="fas fa-eye"></i> + </a> + {% else %} + <button class="btn btn-sm btn-outline-secondary" disabled> + <i class="fas fa-eye-slash"></i> + </button> + {% endif %} </td> </tr> {% empty %} diff --git a/dashboard_project/templates/dashboard/data_view.html b/dashboard_project/templates/dashboard/data_view.html index 0e624e6..127fc7a 100644 --- a/dashboard_project/templates/dashboard/data_view.html +++ b/dashboard_project/templates/dashboard/data_view.html @@ -11,13 +11,13 @@ <h1 class="h2">Data View</h1> <div class="btn-toolbar mb-2 mb-md-0"> <div class="btn-group me-2"> - <a href="{% url 'dashboard' %}" class="btn btn-sm btn-outline-secondary"> + <a href="{% url 'dashboard' %}" class="btn btn-sm btn-outline-secondary ajax-nav-link"> <i class="fas fa-arrow-left"></i> Back to Dashboard </a> {% if selected_data_source %} <a href="{% url 'data_source_detail' selected_data_source.id %}" - class="btn btn-sm btn-outline-secondary" + class="btn btn-sm btn-outline-secondary ajax-nav-link" > <i class="fas fa-database"></i> View Source </a> @@ -34,11 +34,17 @@ <i class="fas fa-filter"></i> Filter </button> <ul class="dropdown-menu" aria-labelledby="dataViewDropdown"> - <li><a class="dropdown-item" href="?view=all">All Sessions</a></li> - <li><a class="dropdown-item" href="?view=recent">Recent Sessions</a></li> - <li><a class="dropdown-item" href="?view=positive">Positive Sentiment</a></li> - <li><a class="dropdown-item" href="?view=negative">Negative Sentiment</a></li> - <li><a class="dropdown-item" href="?view=escalated">Escalated Sessions</a></li> + <li><a class="dropdown-item ajax-nav-link" href="?view=all">All Sessions</a></li> + <li><a class="dropdown-item ajax-nav-link" href="?view=recent">Recent Sessions</a></li> + <li> + <a class="dropdown-item ajax-nav-link" href="?view=positive">Positive Sentiment</a> + </li> + <li> + <a class="dropdown-item ajax-nav-link" href="?view=negative">Negative Sentiment</a> + </li> + <li> + <a class="dropdown-item ajax-nav-link" href="?view=escalated">Escalated Sessions</a> + </li> </ul> </div> </div> @@ -52,7 +58,7 @@ <h5 class="card-title mb-0">Data Source Selection</h5> </div> <div class="card-body"> - <form method="get" class="row g-3 align-items-center"> + <form method="get" class="row g-3 align-items-center filter-form"> <div class="col-md-6"> <select name="data_source_id" class="form-select" aria-label="Select Data Source"> <option value="">All Data Sources</option> @@ -109,153 +115,16 @@ <span class="badge bg-primary">{{ page_obj.paginator.count }} sessions</span> </div> <div class="card-body"> - <div class="table-responsive"> - <table class="table table-striped table-hover"> - <thead> - <tr> - <th>Session ID</th> - <th>Start Time</th> - <th>Country</th> - <th>Language</th> - <th>Messages</th> - <th>Sentiment</th> - <th>Response Time</th> - <th>Category</th> - <th>Actions</th> - </tr> - </thead> - <tbody> - {% for session in page_obj %} - <tr> - <td>{{ session.session_id|truncatechars:10 }}</td> - <td>{{ session.start_time|date:"M d, Y H:i" }}</td> - <td>{{ session.country|default:"N/A" }}</td> - <td>{{ session.language|default:"N/A" }}</td> - <td>{{ session.messages_sent }}</td> - <td> - {% if session.sentiment %} - {% if 'positive' in session.sentiment|lower %} - <span class="badge bg-success">{{ session.sentiment }}</span> - {% elif 'negative' in session.sentiment|lower %} - <span class="badge bg-danger">{{ session.sentiment }}</span> - {% elif 'neutral' in session.sentiment|lower %} - <span class="badge bg-warning">{{ session.sentiment }}</span> - {% else %} - <span class="badge bg-secondary">{{ session.sentiment }}</span> - {% endif %} - {% else %} - <span class="text-muted">N/A</span> - {% endif %} - </td> - <td>{{ session.avg_response_time|floatformat:2 }}s</td> - <td>{{ session.category|default:"N/A" }}</td> - <td> - <a - href="{% url 'chat_session_detail' session.session_id %}" - class="btn btn-sm btn-outline-primary" - > - <i class="fas fa-eye"></i> - </a> - </td> - </tr> - {% empty %} - <tr> - <td colspan="9" class="text-center">No chat sessions found.</td> - </tr> - {% endfor %} - </tbody> - </table> + <!-- Loading spinner shown during AJAX requests --> + <div id="ajax-loading-spinner" class="text-center py-4 d-none"> + <div class="spinner-border text-primary" role="status"> + <span class="visually-hidden">Loading...</span> + </div> + <p class="mt-2">Loading data...</p> </div> - {% if page_obj.paginator.num_pages > 1 %} - <nav aria-label="Page navigation" class="mt-4"> - <ul class="pagination justify-content-center"> - {% if page_obj.has_previous %} - <li class="page-item"> - <a - class="page-link" - href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page=1" - aria-label="First" - > - <span aria-hidden="true">««</span> - </a> - </li> - <li class="page-item"> - <a - class="page-link" - href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ page_obj.previous_page_number }}" - aria-label="Previous" - > - <span aria-hidden="true">«</span> - </a> - </li> - {% else %} - <li class="page-item disabled"> - <a class="page-link" href="#" aria-label="First"> - <span aria-hidden="true">««</span> - </a> - </li> - <li class="page-item disabled"> - <a class="page-link" href="#" aria-label="Previous"> - <span aria-hidden="true">«</span> - </a> - </li> - {% endif %} - - {% for num in page_obj.paginator.page_range %} - {% if page_obj.number == num %} - <li class="page-item active"> - <a - class="page-link" - href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ num }}" - >{{ num }}</a - > - </li> - {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %} - <li class="page-item"> - <a - class="page-link" - href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ num }}" - >{{ num }}</a - > - </li> - {% endif %} - {% endfor %} - - {% if page_obj.has_next %} - <li class="page-item"> - <a - class="page-link" - href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ page_obj.next_page_number }}" - aria-label="Next" - > - <span aria-hidden="true">»</span> - </a> - </li> - <li class="page-item"> - <a - class="page-link" - href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ page_obj.paginator.num_pages }}" - aria-label="Last" - > - <span aria-hidden="true">»»</span> - </a> - </li> - {% else %} - <li class="page-item disabled"> - <a class="page-link" href="#" aria-label="Next"> - <span aria-hidden="true">»</span> - </a> - </li> - <li class="page-item disabled"> - <a class="page-link" href="#" aria-label="Last"> - <span aria-hidden="true">»»</span> - </a> - </li> - {% endif %} - </ul> - </nav> - {% endif %} + <!-- Data table container that will be updated via AJAX --> + <div id="ajax-content-container">{% include "dashboard/partials/data_table.html" %}</div> </div> </div> </div> @@ -314,3 +183,33 @@ </div> {% endif %} {% endblock %} + +{% block extra_js %} + <script> + // Function to update the summary section with new data + function updateSummary(data) { + if (document.querySelector(".stats-card h3:nth-of-type(1)")) { + document.querySelector(".stats-card h3:nth-of-type(1)").textContent = + data.page_obj.paginator.count; + } + if (document.querySelector(".stats-card h3:nth-of-type(2)")) { + document.querySelector(".stats-card h3:nth-of-type(2)").textContent = + data.avg_response_time !== null && data.avg_response_time !== undefined + ? data.avg_response_time.toFixed(2) + "s" + : "0.00s"; + } + if (document.querySelector(".stats-card h3:nth-of-type(3)")) { + document.querySelector(".stats-card h3:nth-of-type(3)").textContent = + data.avg_messages !== null && data.avg_messages !== undefined + ? data.avg_messages.toFixed(1) + : "0.0"; + } + if (document.querySelector(".stats-card h3:nth-of-type(4)")) { + document.querySelector(".stats-card h3:nth-of-type(4)").textContent = + data.escalation_rate !== null && data.escalation_rate !== undefined + ? data.escalation_rate.toFixed(1) + "%" + : "0.0%"; + } + } + </script> +{% endblock %} diff --git a/dashboard_project/templates/dashboard/partials/data_table.html b/dashboard_project/templates/dashboard/partials/data_table.html new file mode 100644 index 0000000..891c2de --- /dev/null +++ b/dashboard_project/templates/dashboard/partials/data_table.html @@ -0,0 +1,160 @@ +<!-- templates/dashboard/partials/data_table.html --> +<div class="table-responsive"> + <table class="table table-striped table-hover"> + <thead> + <tr> + <th>Session ID</th> + <th>Start Time</th> + <th>Country</th> + <th>Language</th> + <th>Messages</th> + <th>Sentiment</th> + <th>Response Time</th> + <th>Category</th> + <th>Actions</th> + </tr> + </thead> + <tbody> + {% for session in page_obj %} + <tr> + <td>{{ session.session_id|truncatechars:10 }}</td> + <td>{{ session.start_time|date:"M d, Y H:i" }}</td> + <td>{{ session.country|default:"N/A" }}</td> + <td>{{ session.language|default:"N/A" }}</td> + <td>{{ session.messages_sent }}</td> + <td> + {% if session.sentiment %} + {% if 'positive' in session.sentiment|lower %} + <span class="badge bg-success">{{ session.sentiment }}</span> + {% elif 'negative' in session.sentiment|lower %} + <span class="badge bg-danger">{{ session.sentiment }}</span> + {% elif 'neutral' in session.sentiment|lower %} + <span class="badge bg-warning">{{ session.sentiment }}</span> + {% else %} + <span class="badge bg-secondary">{{ session.sentiment }}</span> + {% endif %} + {% else %} + <span class="text-muted">N/A</span> + {% endif %} + </td> + <td>{{ session.avg_response_time|floatformat:2 }}s</td> + <td>{{ session.category|default:"N/A" }}</td> + <td> + {% if session.session_id %} + <a + href="{% url 'chat_session_detail' session.session_id %}" + class="btn btn-sm btn-outline-primary ajax-nav-link" + > + <i class="fas fa-eye"></i> + </a> + {% else %} + <button class="btn btn-sm btn-outline-secondary" disabled> + <i class="fas fa-eye-slash"></i> + </button> + {% endif %} + </td> + </tr> + {% empty %} + <tr> + <td colspan="9" class="text-center">No chat sessions found.</td> + </tr> + {% endfor %} + </tbody> + </table> +</div> + +{% if page_obj.paginator.num_pages > 1 %} + <nav aria-label="Page navigation" class="mt-4" id="pagination-container"> + <ul class="pagination justify-content-center"> + {% if page_obj.has_previous %} + <li class="page-item"> + <a + class="page-link pagination-link" + data-page="1" + href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page=1" + aria-label="First" + > + <span aria-hidden="true">««</span> + </a> + </li> + <li class="page-item"> + <a + class="page-link pagination-link" + data-page="{{ page_obj.previous_page_number }}" + href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ page_obj.previous_page_number }}" + aria-label="Previous" + > + <span aria-hidden="true">«</span> + </a> + </li> + {% else %} + <li class="page-item disabled"> + <a class="page-link" href="#" aria-label="First"> + <span aria-hidden="true">««</span> + </a> + </li> + <li class="page-item disabled"> + <a class="page-link" href="#" aria-label="Previous"> + <span aria-hidden="true">«</span> + </a> + </li> + {% endif %} + + {% for num in page_obj.paginator.page_range %} + {% if page_obj.number == num %} + <li class="page-item active"> + <a + class="page-link pagination-link" + data-page="{{ num }}" + href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ num }}" + >{{ num }}</a + > + </li> + {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %} + <li class="page-item"> + <a + class="page-link pagination-link" + data-page="{{ num }}" + href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ num }}" + >{{ num }}</a + > + </li> + {% endif %} + {% endfor %} + + {% if page_obj.has_next %} + <li class="page-item"> + <a + class="page-link pagination-link" + data-page="{{ page_obj.next_page_number }}" + href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ page_obj.next_page_number }}" + aria-label="Next" + > + <span aria-hidden="true">»</span> + </a> + </li> + <li class="page-item"> + <a + class="page-link pagination-link" + data-page="{{ page_obj.paginator.num_pages }}" + href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ page_obj.paginator.num_pages }}" + aria-label="Last" + > + <span aria-hidden="true">»»</span> + </a> + </li> + {% else %} + <li class="page-item disabled"> + <a class="page-link" href="#" aria-label="Next"> + <span aria-hidden="true">»</span> + </a> + </li> + <li class="page-item disabled"> + <a class="page-link" href="#" aria-label="Last"> + <span aria-hidden="true">»»</span> + </a> + </li> + {% endif %} + </ul> + </nav> +{% endif %} diff --git a/dashboard_project/templates/dashboard/partials/search_results_table.html b/dashboard_project/templates/dashboard/partials/search_results_table.html new file mode 100644 index 0000000..33aaed0 --- /dev/null +++ b/dashboard_project/templates/dashboard/partials/search_results_table.html @@ -0,0 +1,164 @@ +<!-- templates/dashboard/partials/search_results_table.html --> +<div class="table-responsive"> + <table class="table table-striped table-hover"> + <thead> + <tr> + <th>Session ID</th> + <th>Start Time</th> + <th>Data Source</th> + <th>Country</th> + <th>Language</th> + <th>Sentiment</th> + <th>Messages</th> + <th>Category</th> + <th>Actions</th> + </tr> + </thead> + <tbody> + {% for session in page_obj %} + <tr> + <td>{{ session.session_id|truncatechars:10 }}</td> + <td>{{ session.start_time|date:"M d, Y H:i" }}</td> + <td> + <a href="{% url 'data_source_detail' session.data_source.id %}" class="ajax-nav-link" + >{{ session.data_source.name|truncatechars:15 }}</a + > + </td> + <td>{{ session.country }}</td> + <td>{{ session.language }}</td> + <td> + {% if session.sentiment %} + {% if 'positive' in session.sentiment|lower %} + <span class="badge bg-success">{{ session.sentiment }}</span> + {% elif 'negative' in session.sentiment|lower %} + <span class="badge bg-danger">{{ session.sentiment }}</span> + {% elif 'neutral' in session.sentiment|lower %} + <span class="badge bg-warning">{{ session.sentiment }}</span> + {% else %} + <span class="badge bg-secondary">{{ session.sentiment }}</span> + {% endif %} + {% else %} + <span class="text-muted">N/A</span> + {% endif %} + </td> + <td>{{ session.messages_sent }}</td> + <td>{{ session.category|default:"N/A" }}</td> + <td> + {% if session.session_id %} + <a + href="{% url 'chat_session_detail' session.session_id %}" + class="btn btn-sm btn-outline-primary" + > + <i class="fas fa-eye"></i> + </a> + {% else %} + <button class="btn btn-sm btn-outline-secondary" disabled> + <i class="fas fa-eye-slash"></i> + </button> + {% endif %} + </td> + </tr> + {% empty %} + <tr> + <td colspan="9" class="text-center">No chat sessions found matching your criteria.</td> + </tr> + {% endfor %} + </tbody> + </table> +</div> + +{% if page_obj.paginator.num_pages > 1 %} + <nav aria-label="Page navigation" class="mt-4" id="pagination-container"> + <ul class="pagination justify-content-center"> + {% if page_obj.has_previous %} + <li class="page-item"> + <a + class="page-link pagination-link" + data-page="1" + href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page=1" + aria-label="First" + > + <span aria-hidden="true">««</span> + </a> + </li> + <li class="page-item"> + <a + class="page-link pagination-link" + data-page="{{ page_obj.previous_page_number }}" + href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ page_obj.previous_page_number }}" + aria-label="Previous" + > + <span aria-hidden="true">«</span> + </a> + </li> + {% else %} + <li class="page-item disabled"> + <a class="page-link" href="#" aria-label="First"> + <span aria-hidden="true">««</span> + </a> + </li> + <li class="page-item disabled"> + <a class="page-link" href="#" aria-label="Previous"> + <span aria-hidden="true">«</span> + </a> + </li> + {% endif %} + + {% for num in page_obj.paginator.page_range %} + {% if page_obj.number == num %} + <li class="page-item active"> + <a + class="page-link pagination-link" + data-page="{{ num }}" + href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ num }}" + >{{ num }}</a + > + </li> + {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %} + <li class="page-item"> + <a + class="page-link pagination-link" + data-page="{{ num }}" + href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ num }}" + >{{ num }}</a + > + </li> + {% endif %} + {% endfor %} + + {% if page_obj.has_next %} + <li class="page-item"> + <a + class="page-link pagination-link" + data-page="{{ page_obj.next_page_number }}" + href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ page_obj.next_page_number }}" + aria-label="Next" + > + <span aria-hidden="true">»</span> + </a> + </li> + <li class="page-item"> + <a + class="page-link pagination-link" + data-page="{{ page_obj.paginator.num_pages }}" + href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ page_obj.paginator.num_pages }}" + aria-label="Last" + > + <span aria-hidden="true">»»</span> + </a> + </li> + {% else %} + <li class="page-item disabled"> + <a class="page-link" href="#" aria-label="Next"> + <span aria-hidden="true">»</span> + </a> + </li> + <li class="page-item disabled"> + <a class="page-link" href="#" aria-label="Last"> + <span aria-hidden="true">»»</span> + </a> + </li> + {% endif %} + </ul> + </nav> +{% endif %} diff --git a/dashboard_project/templates/dashboard/search_results.html b/dashboard_project/templates/dashboard/search_results.html index 0c37a25..aae5303 100644 --- a/dashboard_project/templates/dashboard/search_results.html +++ b/dashboard_project/templates/dashboard/search_results.html @@ -9,7 +9,7 @@ > <h1 class="h2">Search Results</h1> <div class="btn-toolbar mb-2 mb-md-0"> - <a href="{% url 'dashboard' %}" class="btn btn-sm btn-outline-secondary"> + <a href="{% url 'dashboard' %}" class="btn btn-sm btn-outline-secondary ajax-nav-link"> <i class="fas fa-arrow-left"></i> Back to Dashboard </a> </div> @@ -22,7 +22,7 @@ <h5 class="card-title mb-0">Search Chat Sessions</h5> </div> <div class="card-body"> - <form method="get" action="{% url 'search_chat_sessions' %}"> + <form method="get" action="{% url 'search_chat_sessions' %}" class="search-form"> <div class="input-group"> <input type="text" @@ -62,161 +62,24 @@ </h5> </div> <div class="card-body"> - <div class="table-responsive"> - <table class="table table-striped table-hover"> - <thead> - <tr> - <th>Session ID</th> - <th>Start Time</th> - <th>Data Source</th> - <th>Country</th> - <th>Language</th> - <th>Sentiment</th> - <th>Messages</th> - <th>Category</th> - <th>Actions</th> - </tr> - </thead> - <tbody> - {% for session in page_obj %} - <tr> - <td>{{ session.session_id|truncatechars:10 }}</td> - <td>{{ session.start_time|date:"M d, Y H:i" }}</td> - <td> - <a href="{% url 'data_source_detail' session.data_source.id %}" - >{{ session.data_source.name|truncatechars:15 }}</a - > - </td> - <td>{{ session.country }}</td> - <td>{{ session.language }}</td> - <td> - {% if session.sentiment %} - {% if 'positive' in session.sentiment|lower %} - <span class="badge bg-success">{{ session.sentiment }}</span> - {% elif 'negative' in session.sentiment|lower %} - <span class="badge bg-danger">{{ session.sentiment }}</span> - {% elif 'neutral' in session.sentiment|lower %} - <span class="badge bg-warning">{{ session.sentiment }}</span> - {% else %} - <span class="badge bg-secondary">{{ session.sentiment }}</span> - {% endif %} - {% else %} - <span class="text-muted">N/A</span> - {% endif %} - </td> - <td>{{ session.messages_sent }}</td> - <td>{{ session.category|default:"N/A" }}</td> - <td> - <a - href="{% url 'chat_session_detail' session.session_id %}" - class="btn btn-sm btn-outline-primary" - > - <i class="fas fa-eye"></i> - </a> - </td> - </tr> - {% empty %} - <tr> - <td colspan="9" class="text-center"> - No chat sessions found matching your criteria. - </td> - </tr> - {% endfor %} - </tbody> - </table> + <!-- Loading spinner shown during AJAX requests --> + <div id="ajax-loading-spinner" class="text-center py-4 d-none"> + <div class="spinner-border text-primary" role="status"> + <span class="visually-hidden">Loading...</span> + </div> + <p class="mt-2">Loading data...</p> </div> - {% if page_obj.paginator.num_pages > 1 %} - <nav aria-label="Page navigation" class="mt-4"> - <ul class="pagination justify-content-center"> - {% if page_obj.has_previous %} - <li class="page-item"> - <a - class="page-link" - href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page=1" - aria-label="First" - > - <span aria-hidden="true">««</span> - </a> - </li> - <li class="page-item"> - <a - class="page-link" - href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ page_obj.previous_page_number }}" - aria-label="Previous" - > - <span aria-hidden="true">«</span> - </a> - </li> - {% else %} - <li class="page-item disabled"> - <a class="page-link" href="#" aria-label="First"> - <span aria-hidden="true">««</span> - </a> - </li> - <li class="page-item disabled"> - <a class="page-link" href="#" aria-label="Previous"> - <span aria-hidden="true">«</span> - </a> - </li> - {% endif %} - - {% for num in page_obj.paginator.page_range %} - {% if page_obj.number == num %} - <li class="page-item active"> - <a - class="page-link" - href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ num }}" - >{{ num }}</a - > - </li> - {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %} - <li class="page-item"> - <a - class="page-link" - href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ num }}" - >{{ num }}</a - > - </li> - {% endif %} - {% endfor %} - - {% if page_obj.has_next %} - <li class="page-item"> - <a - class="page-link" - href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ page_obj.next_page_number }}" - aria-label="Next" - > - <span aria-hidden="true">»</span> - </a> - </li> - <li class="page-item"> - <a - class="page-link" - href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ page_obj.paginator.num_pages }}" - aria-label="Last" - > - <span aria-hidden="true">»»</span> - </a> - </li> - {% else %} - <li class="page-item disabled"> - <a class="page-link" href="#" aria-label="Next"> - <span aria-hidden="true">»</span> - </a> - </li> - <li class="page-item disabled"> - <a class="page-link" href="#" aria-label="Last"> - <span aria-hidden="true">»»</span> - </a> - </li> - {% endif %} - </ul> - </nav> - {% endif %} + <!-- Search results container that will be updated via AJAX --> + <div id="ajax-content-container"> + {% include "dashboard/partials/search_results_table.html" %} + </div> </div> </div> </div> </div> {% endblock %} + +{% block extra_js %} + <!-- No need for extra JavaScript here, using common ajax-pagination.js --> +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml index 7056689..133a5ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ # docker-compose.yml -version: '3.8' +version: "3.8" services: web: @@ -11,7 +11,7 @@ services: - static_volume:/app/staticfiles - media_volume:/app/media ports: - - '8000:8000' + - "8000:8000" environment: - DEBUG=0 - SECRET_KEY=your_secret_key_here @@ -31,7 +31,7 @@ services: nginx: image: nginx:latest ports: - - '80:80' + - "80:80" volumes: - ./nginx/conf.d:/etc/nginx/conf.d - static_volume:/app/staticfiles