HH/templates/analytics/ask_your_data.html
ismail c5f76b3855
Some checks are pending
Build and Push Docker Image / build (push) Waiting to run
updates
2026-05-11 14:45:30 +03:00

705 lines
23 KiB
HTML

{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% trans "Ask Your Data" %} - PX360{% endblock %}
{% block extra_css %}
<style>
:root {
--arrow-primary: #7c3aed;
--arrow-primary-light: #a78bfa;
--arrow-bg: #f8fafc;
}
.ask-container {
max-width: 900px;
margin: 0 auto;
padding: 2rem 1rem;
}
.ask-header {
text-align: center;
margin-bottom: 2rem;
}
.ask-header h1 {
font-size: 2rem;
font-weight: 800;
color: #1e293b;
margin-bottom: 0.5rem;
}
.ask-header p {
color: #64748b;
font-size: 1rem;
}
/* Search Input */
.ask-input-wrapper {
position: relative;
margin-bottom: 1.5rem;
}
.ask-input {
width: 100%;
padding: 1.25rem 3.5rem 1.25rem 1.5rem;
font-size: 1.1rem;
border: 2px solid #e2e8f0;
border-radius: 1rem;
background: white;
color: #1e293b;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
transition: all 0.2s;
}
.ask-input:focus {
outline: none;
border-color: var(--arrow-primary);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1), 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
.ask-input::placeholder {
color: #94a3b8;
}
.ask-submit {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 0.75rem;
border: none;
background: var(--arrow-primary);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.ask-submit:hover:not(:disabled) {
background: #6d28d9;
transform: translateY(-50%) scale(1.05);
}
.ask-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Suggestions */
.ask-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 2rem;
}
.ask-suggestion {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 9999px;
font-size: 0.85rem;
color: #475569;
cursor: pointer;
transition: all 0.2s;
}
.ask-suggestion:hover {
border-color: var(--arrow-primary);
color: var(--arrow-primary);
background: #f5f3ff;
}
/* History */
.ask-history {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.ask-item {
background: white;
border-radius: 1rem;
border: 1px solid #e2e8f0;
overflow: hidden;
animation: fadeIn 0.3s ease-out;
}
.ask-item-header {
padding: 1rem 1.25rem;
background: var(--arrow-bg);
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.ask-item-question {
font-weight: 600;
color: #1e293b;
flex: 1;
}
.ask-item-time {
font-size: 0.75rem;
color: #94a3b8;
}
.ask-item-content {
padding: 1.25rem;
min-height: 100px;
}
/* States */
.ask-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 3rem 0;
}
.ask-loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #e2e8f0;
border-top-color: var(--arrow-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.ask-loading-text {
color: #64748b;
font-size: 0.9rem;
}
.ask-error {
padding: 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.75rem;
color: #b91c1c;
font-size: 0.9rem;
}
/* Arrow.js Sandbox Container */
.arrow-output {
min-height: 200px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
{% endblock %}
{% block content %}
<div class="ask-container">
<!-- Header -->
<div class="ask-header">
<h1 class="flex items-center justify-center gap-3">
<i data-lucide="message-square" class="w-8 h-8 text-purple-600"></i>
{% trans "Ask Your Data" %}
</h1>
<p>{% trans "Ask questions in natural language — AI generates interactive answers with charts and tables" %}</p>
</div>
<!-- Input -->
<div class="ask-input-wrapper">
<input
type="text"
id="askInput"
class="ask-input"
placeholder="{% trans 'Example: How many complaints did Cardiology have last month?' %}"
onkeydown="if(event.key==='Enter' && !event.shiftKey) submitQuestion()"
>
<button
id="askSubmitBtn"
class="ask-submit"
onclick="submitQuestion()"
>
<i data-lucide="send" class="w-5 h-5"></i>
</button>
</div>
<!-- Suggestions -->
<div class="ask-suggestions" id="askSuggestions">
<button class="ask-suggestion" onclick="askSuggestion(this)">
{% trans "How many complaints this month?" %}
</button>
<button class="ask-suggestion" onclick="askSuggestion(this)">
{% trans "Show complaints by department" %}
</button>
<button class="ask-suggestion" onclick="askSuggestion(this)">
{% trans "What's the average survey score?" %}
</button>
<button class="ask-suggestion" onclick="askSuggestion(this)">
{% trans "Which departments have overdue complaints?" %}
</button>
<button class="ask-suggestion" onclick="askSuggestion(this)">
{% trans "Show NPS trend for the last 6 months" %}
</button>
<button class="ask-suggestion" onclick="askSuggestion(this)">
{% trans "How many actions are overdue?" %}
</button>
<button class="ask-suggestion" onclick="askSuggestion(this)">
{% trans "Top complaint categories this week" %}
</button>
<button class="ask-suggestion" onclick="askSuggestion(this)">
{% trans "Show physician ratings leaderboard" %}
</button>
</div>
<!-- History -->
<div class="ask-history" id="askHistory">
<!-- Questions and answers will be added here -->
</div>
</div>
{% endblock %}
{% block extra_js %}
<script type="module">
import { reactive, html, watch, component } from 'https://esm.sh/@arrow-js/core@latest';
// Make Arrow.js globals available on window for LLM-generated components
window.reactive = reactive;
window.html = html;
window.watch = watch;
import { sandbox } from 'https://esm.sh/@arrow-js/sandbox@latest';
// Global state
const state = reactive({
isLoading: false,
questions: [],
currentId: null,
error: null,
csrfToken: '{{ csrf_token }}',
conversationHistory: [], // {role: "user"|"assistant", content: "...", data: {...}}
});
// Make state globally accessible for the sandbox callback
window.askState = state;
// Submit a question
window.submitQuestion = function() {
const input = document.getElementById('askInput');
const question = input.value.trim();
if (!question || state.isLoading) return;
input.value = '';
state.isLoading = true;
state.error = null;
const id = Date.now().toString();
const q = reactive({
id,
question,
answer: null,
status: 'loading', // loading | generating | ready | error
component: null,
timestamp: new Date(),
});
state.questions = [{ ...state.questions[0] }, q, ...state.questions.slice(1)].slice(0, 20);
state.currentId = id;
// Hide suggestions after first question
const suggestions = document.getElementById('askSuggestions');
if (suggestions) suggestions.style.display = 'none';
// Render the question card
renderHistory();
// Send to backend with conversation history
const historyPayload = state.conversationHistory.slice(-8); // last 8 messages
fetch('{% url "analytics:ask_data_query" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': state.csrfToken,
},
body: JSON.stringify({ question, conversation_history: historyPayload }),
})
.then(r => r.json())
.then(data => {
if (data.error) {
q.status = 'error';
q.answer = data.error;
// Still add to history even on error
state.conversationHistory.push({ role: 'user', content: question });
state.conversationHistory.push({ role: 'assistant', content: data.error });
} else {
q.status = 'ready';
q.answer = data.answer || '';
q.data = data.data || {};
q.chartType = data.chart_type || 'table';
q.component = data.component || null;
// Update conversation history
state.conversationHistory.push({ role: 'user', content: question });
state.conversationHistory.push({ role: 'assistant', content: data.answer, data: data.data, chart_type: data.chart_type });
// Keep only last 10 messages (5 exchanges)
if (state.conversationHistory.length > 10) {
state.conversationHistory = state.conversationHistory.slice(-10);
}
}
})
.catch(err => {
console.error('Ask data error:', err);
q.status = 'error';
q.answer = 'Failed to get answer. Please try again.';
})
.finally(() => {
state.isLoading = false;
renderHistory();
});
};
// Click a suggestion
window.askSuggestion = function(btn) {
document.getElementById('askInput').value = btn.textContent.trim();
submitQuestion();
};
// Render the history
function renderHistory() {
const container = document.getElementById('askHistory');
container.innerHTML = '';
state.questions.forEach(q => {
if (!q || !q.id) return;
const item = document.createElement('div');
item.className = 'ask-item';
item.id = `ask-${q.id}`;
const time = q.timestamp ? new Date(q.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
item.innerHTML = `
<div class="ask-item-header">
<span class="ask-item-question">❓ ${escapeHtml(q.question)}</span>
<span class="ask-item-time">${time}</span>
</div>
<div class="ask-item-content">
<div class="ask-loading">
<div class="ask-loading-spinner"></div>
<div class="ask-loading-text">${q.status === 'loading' ? 'Analyzing question...' : 'Generating interactive answer...'}</div>
</div>
</div>
`;
container.appendChild(item);
// If ready, render content
if (q.status === 'ready' || q.status === 'error') {
const contentEl = item.querySelector('.ask-item-content');
renderContent(contentEl, q);
}
});
// Re-initialize lucide icons
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function renderContent(container, q) {
container.innerHTML = '';
if (q.status === 'error') {
container.innerHTML = `<div class="ask-error">⚠️ ${escapeHtml(q.answer || 'An error occurred')}</div>`;
return;
}
// Answer text (narrative)
if (q.answer) {
const narrative = document.createElement('div');
narrative.className = 'mb-4 p-3 bg-purple-50 rounded-lg text-sm text-gray-700 leading-relaxed';
narrative.textContent = q.answer;
container.appendChild(narrative);
}
// Arrow.js interactive component
if (q.component) {
// Set data for the component
window.askData = q.data || {};
try {
// Wrap component code to handle exports
let code = q.component;
// Remove import statements (we already have globals)
code = code.replace(/import\s+.*?from\s+['"][^'"]+['"];?/g, '');
// Handle export default
const exportMatch = code.match(/export\s+default\s+(.+)/s);
if (exportMatch) {
const viewExpr = exportMatch[1].trim();
// Remove the export line
code = code.replace(/export\s+default\s+.+/s, '');
// Execute the component code
const fn = new Function('html', 'reactive', 'watch', 'ApexCharts',
code + '\nreturn ' + viewExpr + ';');
const view = fn(window.html, window.reactive, window.watch, window.ApexCharts);
if (typeof view === 'function') {
const arrowContainer = document.createElement('div');
arrowContainer.className = 'arrow-output mb-4';
container.appendChild(arrowContainer);
view(arrowContainer);
return; // Success — stop here, no fallback
}
}
} catch (e) {
console.error('Arrow.js component error:', e);
// Fall through to static rendering
}
}
// Fallback: static rendering (no component or component failed)
if (q.data && Object.keys(q.data).length > 0 && !q.data.error) {
renderFallbackContent(container, q);
}
}
function renderFallbackContent(container, q) {
const vizContainer = document.createElement('div');
vizContainer.className = 'arrow-output';
container.appendChild(vizContainer);
const chartType = q.chartType || 'table';
if (chartType === 'metric' && q.data.value !== undefined) {
renderMetric(vizContainer, q.data);
} else if (chartType === 'bar' && q.data.headers && q.data.rows) {
renderBarChart(vizContainer, q.data);
} else if (chartType === 'line' && q.data.headers && q.data.rows) {
renderLineChart(vizContainer, q.data);
} else if (q.data.headers && q.data.rows) {
renderTable(vizContainer, q.data);
} else {
vizContainer.innerHTML = '<pre class="text-sm bg-gray-50 p-4 rounded-lg overflow-auto">' + JSON.stringify(q.data, null, 2) + '</pre>';
}
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function escapeForJs(str) {
if (!str) return '';
return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, '\\n');
}
function renderTable(container, data) {
if (!data || !data.headers || !data.rows || data.rows.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-sm p-4 text-center">No data found</p>';
return;
}
let html = '<table class="w-full text-sm" style="border-collapse:collapse;">';
html += '<thead><tr>';
data.headers.forEach(h => {
html += `<th class="px-3 py-2 text-left text-xs font-bold text-gray-500 uppercase bg-gray-50 border-b border-gray-200">${escapeHtml(h)}</th>`;
});
html += '</tr></thead><tbody>';
data.rows.forEach((row) => {
html += '<tr class="hover:bg-gray-50 transition">';
data.headers.forEach(h => {
let val = row[h];
if (val === null || val === undefined) val = '—';
html += `<td class="px-3 py-2 text-gray-700 border-b border-gray-100">${escapeHtml(String(val))}</td>`;
});
html += '</tr>';
});
html += '</tbody></table>';
html += `<div class="text-xs text-gray-400 px-3 py-2">${data.rows.length} record(s)</div>`;
container.innerHTML = html;
}
function renderMetric(container, data) {
const value = data.value !== undefined ? data.value : '—';
let suffix = '';
let color = '#7c3aed';
if (data.numerator !== undefined && data.denominator !== undefined) {
suffix = `<div style="font-size:0.875rem;color:#64748b;margin-top:0.5rem;">${data.numerator} out of ${data.denominator}</div>`;
}
container.innerHTML = `
<div style="text-align:center;padding:2rem;">
<div style="font-size:3.5rem;font-weight:800;color:${color};">${value}${suffix ? '' : ''}</div>
${suffix}
</div>
`;
}
function renderBarChart(container, data) {
if (!data.rows || data.rows.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-sm p-4 text-center">No data found</p>';
return;
}
const labelKey = data.headers[0];
const valueKey = data.headers[1];
const labels = data.rows.map(r => r[labelKey] || 'Unknown');
const values = data.rows.map(r => r[valueKey] || 0);
const colors = ['#7c3aed', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'];
const chartDiv = document.createElement('div');
chartDiv.className = 'bar-chart-apex';
container.appendChild(chartDiv);
const options = {
series: [{ name: valueKey, data: values }],
chart: { type: 'bar', height: 280, toolbar: { show: false } },
plotOptions: { bar: { borderRadius: 4, columnWidth: '60%', distributed: true } },
colors: colors,
dataLabels: { enabled: false },
legend: { show: false },
xaxis: { categories: labels, labels: { rotate: -45, style: { fontSize: '11px' } } },
yaxis: { title: { text: valueKey } },
tooltip: { y: { formatter: v => v } },
fill: { opacity: 0.9 },
};
if (typeof ApexCharts !== 'undefined') {
const chart = new ApexCharts(chartDiv, options);
chart.render();
} else {
// Fallback to CSS bars
_renderFallbackBars(container, labels, values, labelKey, valueKey);
}
// Collapsible table below
const details = document.createElement('details');
details.className = 'mt-4';
details.innerHTML = '<summary class="cursor-pointer text-xs text-gray-500">Show data table</summary>';
let tableHtml = '<table class="w-full text-sm mt-2" style="border-collapse:collapse;">';
tableHtml += '<thead><tr>';
data.headers.forEach(h => {
tableHtml += `<th class="px-3 py-2 text-left text-xs font-bold text-gray-500 uppercase bg-gray-50 border-b border-gray-200">${escapeHtml(h)}</th>`;
});
tableHtml += '</tr></thead><tbody>';
data.rows.forEach(row => {
tableHtml += '<tr class="hover:bg-gray-50">';
data.headers.forEach(h => {
let val = row[h];
if (val === null || val === undefined) val = '—';
tableHtml += `<td class="px-3 py-2 text-gray-700 border-b border-gray-100">${escapeHtml(String(val))}</td>`;
});
tableHtml += '</tr>';
});
tableHtml += '</tbody></table>';
details.innerHTML += tableHtml;
container.appendChild(details);
}
function renderLineChart(container, data) {
if (!data.rows || data.rows.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-sm p-4 text-center">No trend data available</p>';
return;
}
const dateKey = data.headers[0];
const countKey = data.headers[1];
const labels = data.rows.map(r => r[dateKey]);
const values = data.rows.map(r => r[countKey] || 0);
const chartDiv = document.createElement('div');
chartDiv.className = 'line-chart-apex';
container.appendChild(chartDiv);
const options = {
series: [{ name: countKey, data: values }],
chart: { type: 'area', height: 260, toolbar: { show: false }, zoom: { enabled: true } },
stroke: { curve: 'smooth', width: 2.5 },
fill: { type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: 0.4, opacityTo: 0.1, stops: [0, 90, 100] } },
colors: ['#7c3aed'],
dataLabels: { enabled: true, style: { fontSize: '11px' } },
xaxis: { categories: labels, labels: { rotate: -45, style: { fontSize: '11px' } } },
yaxis: { title: { text: countKey } },
tooltip: { x: { show: true }, y: { formatter: v => v } },
markers: { size: 3, colors: ['#7c3aed'], strokeColors: '#fff', strokeWidth: 2 },
grid: { borderColor: '#e2e8f0', strokeDashArray: 3 },
};
if (typeof ApexCharts !== 'undefined') {
const chart = new ApexCharts(chartDiv, options);
chart.render();
} else {
container.innerHTML = '<p class="text-gray-500 text-sm p-4">Chart library unavailable</p>';
}
// Collapsible table below
const details = document.createElement('details');
details.className = 'mt-4';
details.innerHTML = '<summary class="cursor-pointer text-xs text-gray-500">Show data table</summary>';
let tableHtml = '<table class="w-full text-sm mt-2" style="border-collapse:collapse;">';
tableHtml += '<thead><tr>';
data.headers.forEach(h => {
tableHtml += `<th class="px-3 py-2 text-left text-xs font-bold text-gray-500 uppercase bg-gray-50 border-b border-gray-200">${escapeHtml(h)}</th>`;
});
tableHtml += '</tr></thead><tbody>';
data.rows.forEach(row => {
tableHtml += '<tr class="hover:bg-gray-50">';
data.headers.forEach(h => {
let val = row[h];
if (val === null || val === undefined) val = '—';
tableHtml += `<td class="px-3 py-2 text-gray-700 border-b border-gray-100">${escapeHtml(String(val))}</td>`;
});
tableHtml += '</tr>';
});
tableHtml += '</tbody></table>';
details.innerHTML += tableHtml;
container.appendChild(details);
}
function _renderFallbackBars(container, labels, values, labelKey, valueKey) {
const maxVal = Math.max(...values, 1);
const colors = ['#7c3aed', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#14b8a6', '#f97316'];
let html = '<div style="padding:0.5rem 0;">';
labels.forEach((label, i) => {
const val = values[i] || 0;
const pct = (val / maxVal * 100);
html += `<div style="margin-bottom:0.5rem;">
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:2px;">
<span style="font-weight:600;">${escapeHtml(label)}</span>
<span style="color:#64748b;">${val}</span>
</div>
<div style="height:8px;background:#e2e8f0;border-radius:4px;overflow:hidden;">
<div style="height:100%;width:${pct}%;background:${colors[i % colors.length]};border-radius:4px;"></div>
</div>
</div>`;
});
html += '</div>';
container.innerHTML += html;
}
// Initialize lucide icons
document.addEventListener('DOMContentLoaded', () => {
if (typeof lucide !== 'undefined') lucide.createIcons();
// Focus input
const input = document.getElementById('askInput');
if (input) input.focus();
});
</script>
<!-- Pass hospital context to JS -->
{% if selected_hospital %}
<script>var requestHospitalId = '{{ selected_hospital.id }}';</script>
{% endif %}
{% endblock %}