Enhance AJAX navigation and pagination across dashboard templates

- Implemented AJAX-based navigation for links and forms to improve user experience.
- Added loading indicators during AJAX requests to enhance feedback.
- Refactored data tables and search results to load content dynamically via AJAX.
- Created partial templates for data tables and search results to streamline rendering.
- Updated pagination links to work with AJAX, maintaining browser history.
- Added JavaScript files for handling AJAX navigation and pagination.
- Improved session detail view with conditional rendering for action buttons.
- Updated Docker Compose file for consistency in version formatting.
- Created a TODO list for future enhancements and features.
This commit is contained in:
2025-05-17 02:28:38 +02:00
parent fe69bdbc94
commit 482bea1ba5
21 changed files with 1275 additions and 565 deletions

View File

@ -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]

View File

@ -16,15 +16,11 @@
"useTabs": false,
"overrides": [
{
"files": [
"*.html"
],
"files": ["*.html"],
"options": {
"parser": "jinja-template"
}
}
],
"plugins": [
"prettier-plugin-jinja-template"
]
"plugins": ["prettier-plugin-jinja-template"]
}

View File

@ -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

View File

@ -55,10 +55,7 @@ If you need to prevent Prettier from formatting a section of your template:
```html
{# prettier-ignore #}
<div>
This section will not be formatted
by Prettier.
</div>
<div>This section will not be formatted by Prettier.</div>
<!-- prettier-ignore -->
<div>

View File

@ -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

View File

@ -92,10 +92,12 @@ This will create:
### Admin Tasks
1. **Access Admin Panel**:
- Go to <http://localhost/admin/>
- 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 <http://localhost/>
- 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
@ -144,24 +150,24 @@ 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 |
| ------------------- | ------------------------------- | -------- |
| `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:

View File

@ -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.

13
TODO.md Normal file
View File

@ -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

View File

@ -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)

View File

@ -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 */
}

View File

@ -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 =
'<div class="progress" style="height: 3px; border-radius: 0;"><div class="progress-bar progress-bar-striped progress-bar-animated bg-primary" style="width: 100%"></div></div>';
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>(.*?)<\/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);
}
});
}
});

View File

@ -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);
}
});
}
});

View File

@ -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,9 +121,20 @@ 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) {
if (timeSeriesData && timeSeriesData.length > 0) {
try {
const timeSeriesX = timeSeriesData.map((item) => item.date);
const timeSeriesY = timeSeriesData.map((item) => item.count);
@ -149,6 +166,14 @@ document.addEventListener("DOMContentLoaded", function () {
},
}
);
} 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

View File

@ -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 %} #}
<div id="main-content">
{% block content %}
{% endblock %}
</div>
</main>
</div>
</div>
<footer>
<div class="container">
<p>&copy; {% now "Y" %} Chat Analytics Dashboard. All rights reserved.</p>
<p>&copy; {% 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 }}

View File

@ -147,13 +147,30 @@
{% 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);
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);
// Sessions over time chart
const timeSeriesData = dashboardData.time_series_data;
if (timeSeriesData && timeSeriesData.length > 0) {
const timeSeriesX = timeSeriesData.map((item) => item.date);
const timeSeriesY = timeSeriesData.map((item) => item.count);
@ -185,11 +202,13 @@
},
}
);
} 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;
if (sentimentData.length > 0) {
if (sentimentData && sentimentData.length > 0) {
const sentimentLabels = sentimentData.map((item) => item.sentiment);
const sentimentValues = sentimentData.map((item) => item.count);
const sentimentColors = sentimentLabels.map((sentiment) => {
@ -224,9 +243,7 @@
}
// Country chart
const countryData = dashboardData.country_data;
if (countryData.length > 0) {
if (countryData && countryData.length > 0) {
const countryLabels = countryData.map((item) => item.country);
const countryValues = countryData.map((item) => item.count);
@ -256,9 +273,7 @@
}
// Category chart
const categoryData = dashboardData.category_data;
if (categoryData.length > 0) {
if (categoryData && categoryData.length > 0) {
const categoryLabels = categoryData.map((item) => item.category);
const categoryValues = categoryData.map((item) => item.count);
@ -281,6 +296,13 @@
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>';
});
}
});
</script>
{% endblock %}

View File

@ -115,12 +115,18 @@
<td>{{ session.tokens }}</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 %}

View File

@ -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">&laquo;&laquo;</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">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</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">&raquo;</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">&raquo;&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</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 %}

View File

@ -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">&laquo;&laquo;</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">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</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">&raquo;</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">&raquo;&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}

View File

@ -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">&laquo;&laquo;</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">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</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">&raquo;</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">&raquo;&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}

View File

@ -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">&laquo;&laquo;</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">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</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">&raquo;</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">&raquo;&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</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 %}

View File

@ -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