705 lines
23 KiB
HTML
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: false },
|
|
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 %}
|