first push message
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
'name': "dashboard_survey",
|
||||
|
||||
'summary': "Short (1 phrase/line) summary of the module's purpose",
|
||||
|
||||
'description': """
|
||||
Long description of module's purpose
|
||||
""",
|
||||
|
||||
'author': "My Company",
|
||||
'website': "https://www.yourcompany.com",
|
||||
|
||||
# Categories can be used to filter modules in modules listing
|
||||
# Check https://github.com/odoo/odoo/blob/15.0/odoo/addons/base/data/ir_module_category_data.xml
|
||||
# for the full list
|
||||
'category': 'Uncategorized',
|
||||
'version': '0.1',
|
||||
|
||||
# any module necessary for this one to work correctly
|
||||
'depends': ['survey', 'web'],
|
||||
|
||||
# always loaded
|
||||
'data': [
|
||||
# 'security/ir.model.access.csv',
|
||||
'views/views.xml',
|
||||
# 'views/dashboard_template.xml',
|
||||
],
|
||||
# only loaded in demonstration mode
|
||||
'demo': [
|
||||
'demo/demo.xml',
|
||||
],
|
||||
# 'assets': {
|
||||
# 'web.assets_frontend': [
|
||||
# 'dashboard_survey/static/src/css/dashboard.css',
|
||||
# 'dashboard_survey/static/src/js/dashboard.js',
|
||||
# ],
|
||||
# },
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import main
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
# from odoo import http
|
||||
|
||||
|
||||
# class DashboardSurvey(http.Controller):
|
||||
# @http.route('/dashboard_survey/dashboard_survey', auth='public')
|
||||
# def index(self, **kw):
|
||||
# return "Hello, world"
|
||||
|
||||
# @http.route('/dashboard_survey/dashboard_survey/objects', auth='public')
|
||||
# def list(self, **kw):
|
||||
# return http.request.render('dashboard_survey.listing', {
|
||||
# 'root': '/dashboard_survey/dashboard_survey',
|
||||
# 'objects': http.request.env['dashboard_survey.dashboard_survey'].search([]),
|
||||
# })
|
||||
|
||||
# @http.route('/dashboard_survey/dashboard_survey/objects/<model("dashboard_survey.dashboard_survey"):obj>', auth='public')
|
||||
# def object(self, obj, **kw):
|
||||
# return http.request.render('dashboard_survey.object', {
|
||||
# 'object': obj
|
||||
# })
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import json
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class SurveyDashboardController(http.Controller):
|
||||
|
||||
@http.route('/survey/dashboard/<int:survey_id>', type='http', auth='user', website=True)
|
||||
def dashboard_view(self, survey_id, **kw):
|
||||
survey = request.env['survey.survey'].sudo().browse(survey_id).exists()
|
||||
if not survey:
|
||||
return request.not_found()
|
||||
|
||||
# Get data from model
|
||||
data = survey.get_dashboard_data()
|
||||
|
||||
# ✅ CRITICAL: Serialize to JSON string with UTF-8 support for Khmer text
|
||||
data['questions_json'] = json.dumps(
|
||||
data.get('questions', []),
|
||||
default=str,
|
||||
ensure_ascii=False
|
||||
)
|
||||
|
||||
return request.render('dashboard_survey.dashboard_template', data)
|
||||
@@ -0,0 +1,30 @@
|
||||
<odoo>
|
||||
<data>
|
||||
<!--
|
||||
<record id="object0" model="dashboard_survey.dashboard_survey">
|
||||
<field name="name">Object 0</field>
|
||||
<field name="value">0</field>
|
||||
</record>
|
||||
|
||||
<record id="object1" model="dashboard_survey.dashboard_survey">
|
||||
<field name="name">Object 1</field>
|
||||
<field name="value">10</field>
|
||||
</record>
|
||||
|
||||
<record id="object2" model="dashboard_survey.dashboard_survey">
|
||||
<field name="name">Object 2</field>
|
||||
<field name="value">20</field>
|
||||
</record>
|
||||
|
||||
<record id="object3" model="dashboard_survey.dashboard_survey">
|
||||
<field name="name">Object 3</field>
|
||||
<field name="value">30</field>
|
||||
</record>
|
||||
|
||||
<record id="object4" model="dashboard_survey.dashboard_survey">
|
||||
<field name="name">Object 4</field>
|
||||
<field name="value">40</field>
|
||||
</record>
|
||||
-->
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import survey
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,81 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class SurveyDashboardView(models.Model):
|
||||
_name = 'survey.dashboard.view'
|
||||
_description = 'Survey Dashboard View'
|
||||
_auto = False
|
||||
_order = 'create_date desc'
|
||||
_rec_name = 'question_id'
|
||||
|
||||
survey_id = fields.Many2one('survey.survey', string='Survey', readonly=True)
|
||||
user_input_id = fields.Many2one('survey.user_input', string='User Input', readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Respondent', readonly=True)
|
||||
question_id = fields.Many2one('survey.question', string='Question', readonly=True)
|
||||
|
||||
answer_type = fields.Selection([
|
||||
('simple_choice', 'Multiple choice: only one answer'),
|
||||
('multiple_choice', 'Multiple choice: multiple answers allowed'),
|
||||
('text_box', 'Multiple Lines Text Box'),
|
||||
('char_box', 'Single Line Text Box'),
|
||||
('numerical_box', 'Numerical Value'),
|
||||
('scale', 'Scale'),
|
||||
('date', 'Date'),
|
||||
('datetime', 'Datetime'),
|
||||
('matrix', 'Matrix')
|
||||
], string='Question Type', readonly=True)
|
||||
|
||||
answer_value = fields.Char(string='Answer Value', readonly=True)
|
||||
response_count = fields.Integer(string='Count', readonly=True)
|
||||
create_date = fields.Datetime(string='Response Date', readonly=True)
|
||||
is_done = fields.Boolean(string='Completed', readonly=True)
|
||||
|
||||
def init(self):
|
||||
# Drop view first to allow structural changes
|
||||
self.env.cr.execute("DROP VIEW IF EXISTS survey_dashboard_view")
|
||||
|
||||
self.env.cr.execute("""
|
||||
CREATE VIEW survey_dashboard_view AS (
|
||||
SELECT
|
||||
ROW_NUMBER() OVER () as id,
|
||||
sui.id as user_input_id,
|
||||
sui.survey_id as survey_id,
|
||||
sui.partner_id as partner_id,
|
||||
sq.id as question_id,
|
||||
sq.question_type as answer_type,
|
||||
|
||||
-- ✅ FIX: Explicitly cast ALL branches to TEXT
|
||||
CASE
|
||||
WHEN sq.question_type = 'matrix' THEN
|
||||
CONCAT(sqa_row.value::text, ': ', sqa_col.value::text)
|
||||
|
||||
WHEN sq.question_type IN ('simple_choice', 'multiple_choice') THEN
|
||||
sqa_col.value::text
|
||||
|
||||
ELSE COALESCE(
|
||||
uls.value_text_box::text,
|
||||
uls.value_char_box::text,
|
||||
uls.value_date::text,
|
||||
uls.value_datetime::text,
|
||||
uls.value_numerical_box::text,
|
||||
uls.value_scale::text,
|
||||
''
|
||||
)
|
||||
END as answer_value,
|
||||
|
||||
1 as response_count,
|
||||
sui.create_date as create_date,
|
||||
(sui.state = 'done') as is_done
|
||||
FROM survey_user_input sui
|
||||
LEFT JOIN survey_user_input_line uls ON uls.user_input_id = sui.id
|
||||
LEFT JOIN survey_question sq ON sq.id = uls.question_id
|
||||
|
||||
-- Joins for Multiple Choice and Matrix answers
|
||||
LEFT JOIN survey_question_answer sqa_col ON uls.suggested_answer_id = sqa_col.id
|
||||
LEFT JOIN survey_question_answer sqa_row ON uls.matrix_row_id = sqa_row.id
|
||||
|
||||
WHERE sui.state IN ('done')
|
||||
AND sq.is_page = False
|
||||
AND sq.question_type IS NOT NULL
|
||||
)
|
||||
""")
|
||||
@@ -0,0 +1,130 @@
|
||||
from odoo import models, fields, api
|
||||
import json
|
||||
import textwrap
|
||||
|
||||
class SurveySurvey(models.Model):
|
||||
_inherit = 'survey.survey'
|
||||
|
||||
def get_dashboard_data(self):
|
||||
self.ensure_one()
|
||||
# Get completed responses only
|
||||
completed = self.user_input_ids.filtered(lambda u: u.state == 'done')
|
||||
total = len(completed)
|
||||
completion_rate = round((total / max(len(self.user_input_ids), 1)) * 100, 1) if self.user_input_ids else 0
|
||||
|
||||
questions_data = []
|
||||
for q in self.question_and_page_ids:
|
||||
if q.is_page:
|
||||
questions_data.append({'type': 'page', 'id': q.id, 'title': q.title or ''})
|
||||
continue
|
||||
|
||||
# Filter lines for this question
|
||||
lines = completed.mapped('user_input_line_ids').filtered(lambda l: l.question_id == q)
|
||||
|
||||
q_data = {
|
||||
'type': 'question',
|
||||
'id': q.id,
|
||||
'title': q.title or '',
|
||||
'qtype': q.question_type or '',
|
||||
'stats': [],
|
||||
'text_answers': []
|
||||
}
|
||||
|
||||
# Choice / Matrix / Scale
|
||||
if q.question_type in ['simple_choice', 'multiple_choice', 'matrix', 'scale']:
|
||||
for ans in q.suggested_answer_ids:
|
||||
count = len(lines.filtered(lambda l: l.suggested_answer_id == ans))
|
||||
pct = round((count / total * 100) if total else 0, 2)
|
||||
q_data['stats'].append({
|
||||
'label': ans.value or '',
|
||||
'count': count,
|
||||
'percent': pct
|
||||
})
|
||||
|
||||
# Text / Char
|
||||
elif q.question_type in ['text_box', 'char_box']:
|
||||
for line in lines:
|
||||
val = line.value_text_box or line.value_char_box
|
||||
if val:
|
||||
q_data['text_answers'].append(str(val))
|
||||
|
||||
# Numerical / Date
|
||||
elif q.question_type in ['numerical_box', 'date', 'datetime']:
|
||||
for line in lines:
|
||||
val = getattr(line, f'value_{q.question_type}', None)
|
||||
if val is not None:
|
||||
q_data['text_answers'].append(str(val))
|
||||
|
||||
questions_data.append(q_data)
|
||||
|
||||
# ✅ Serialize in Python (fixes QWeb KeyError: 'JSON')
|
||||
questions_json = json.dumps(questions_data, default=str, ensure_ascii=False)
|
||||
|
||||
return {
|
||||
'survey': self,
|
||||
'total_responses': total,
|
||||
'completion_rate': completion_rate,
|
||||
'questions': questions_data,
|
||||
'questions_json': questions_json,
|
||||
'print_url': f'/survey/print/{self.access_token}' if self.access_token else '#'
|
||||
}
|
||||
|
||||
|
||||
class SurveyUserInputLine(models.Model):
|
||||
_inherit = 'survey.user_input.line'
|
||||
|
||||
display_answer = fields.Char(
|
||||
string="Normalized Answer",
|
||||
compute='_compute_display_answer',
|
||||
store=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
@api.depends('answer_type',
|
||||
'value_char_box', 'value_text_box', 'value_numerical_box',
|
||||
'value_scale', 'value_date', 'value_datetime',
|
||||
'suggested_answer_id', 'suggested_answer_id.value',
|
||||
'matrix_row_id', 'matrix_row_id.value')
|
||||
def _compute_display_answer(self):
|
||||
for line in self:
|
||||
answer = ''
|
||||
|
||||
# 🟢 Choice Questions (simple_choice, multiple_choice, matrix)
|
||||
if line.answer_type == 'suggestion':
|
||||
col_val = line.suggested_answer_id.value if line.suggested_answer_id else ''
|
||||
row_val = line.matrix_row_id.value if line.matrix_row_id else ''
|
||||
|
||||
if row_val and col_val:
|
||||
# Matrix question: "Row Label: Column Label"
|
||||
answer = f"{row_val}: {col_val}"
|
||||
else:
|
||||
# Simple/Multiple choice: just the selected option
|
||||
answer = col_val
|
||||
|
||||
# 📝 Text Answers
|
||||
elif line.answer_type == 'char_box':
|
||||
answer = (line.value_char_box or '').strip()
|
||||
elif line.answer_type == 'text_box':
|
||||
# Optional: truncate long text for dashboard readability
|
||||
txt = (line.value_text_box or '').strip()
|
||||
answer = textwrap.shorten(txt, width=100, placeholder=" [...]") if txt else ''
|
||||
|
||||
# 🔢 Numeric Answers
|
||||
elif line.answer_type == 'numerical_box':
|
||||
answer = str(line.value_numerical_box) if line.value_numerical_box is not None else ''
|
||||
elif line.answer_type == 'scale':
|
||||
answer = str(line.value_scale) if line.value_scale else ''
|
||||
|
||||
# 📅 Date/Time Answers
|
||||
elif line.answer_type == 'date':
|
||||
answer = fields.Date.to_string(line.value_date) if line.value_date else ''
|
||||
elif line.answer_type == 'datetime':
|
||||
answer = fields.Datetime.to_string(
|
||||
fields.Datetime.context_timestamp(self.env.user, line.value_datetime)
|
||||
) if line.value_datetime else ''
|
||||
|
||||
# ⚪ Skipped or Unknown
|
||||
else:
|
||||
answer = 'Skipped' if line.skipped else ''
|
||||
|
||||
line.display_answer = answer
|
||||
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_dashboard_survey_dashboard_survey,dashboard_survey.dashboard_survey,model_survey_dashboard_view,base.group_user,1,1,1,1
|
||||
|
@@ -0,0 +1,23 @@
|
||||
.dashboard-scroll-wrapper {
|
||||
max-height: calc(100vh - 260px);
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f8f9fa;
|
||||
}
|
||||
.dashboard-scroll-wrapper::-webkit-scrollbar { width: 6px; }
|
||||
.dashboard-scroll-wrapper::-webkit-scrollbar-track { background: #f8f9fa; border-radius: 4px; }
|
||||
.dashboard-scroll-wrapper::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
.dashboard-scroll-wrapper::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
.text-answers-scroll {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.view-section { animation: fadeIn 0.25s ease-out; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.card { border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.progress { background-color: #eaecf4; border-radius: 4px; }
|
||||
+20
File diff suppressed because one or more lines are too long
@@ -0,0 +1,145 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Global variables
|
||||
let questionsData = [];
|
||||
let currentView = 'list';
|
||||
let currentQuestionId = null;
|
||||
let pieChartInstance = null;
|
||||
let barChartInstance = null;
|
||||
|
||||
// Wait for DOM to load
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const root = document.getElementById('dashboard-root');
|
||||
if (!root) return;
|
||||
|
||||
try {
|
||||
// ✅ Get JSON data from the data-* attribute
|
||||
const jsonStr = root.getAttribute('data-questions-json');
|
||||
if (!jsonStr) {
|
||||
console.error('No questions data found in attribute.');
|
||||
return;
|
||||
}
|
||||
questionsData = JSON.parse(jsonStr);
|
||||
|
||||
// Set default question
|
||||
const firstQ = questionsData.find(q => q.type === 'question');
|
||||
if (firstQ) {
|
||||
currentQuestionId = firstQ.id;
|
||||
const selector = document.getElementById('question-selector');
|
||||
if (selector) selector.value = firstQ.id;
|
||||
}
|
||||
|
||||
// Attach Event Listeners to Buttons
|
||||
document.getElementById('btn-list').addEventListener('click', () => switchView('list'));
|
||||
document.getElementById('btn-pie').addEventListener('click', () => switchView('pie'));
|
||||
document.getElementById('btn-bar').addEventListener('click', () => switchView('bar'));
|
||||
|
||||
// Attach Event Listener to Selector
|
||||
document.getElementById('question-selector').addEventListener('change', function(e) {
|
||||
currentQuestionId = parseInt(e.target.value);
|
||||
renderCurrentView();
|
||||
});
|
||||
|
||||
// Initial render
|
||||
renderCurrentView();
|
||||
|
||||
} catch (e) {
|
||||
console.error('Dashboard Init Error:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Function to switch views
|
||||
function switchView(viewName) {
|
||||
currentView = viewName;
|
||||
|
||||
// Update Button Styles
|
||||
const buttons = ['btn-list', 'btn-pie', 'btn-bar'];
|
||||
buttons.forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) {
|
||||
if (id === 'btn-' + viewName) {
|
||||
btn.classList.add('btn-primary', 'active');
|
||||
btn.classList.remove('btn-outline-primary');
|
||||
} else {
|
||||
btn.classList.remove('btn-primary', 'active');
|
||||
btn.classList.add('btn-outline-primary');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle View Visibility
|
||||
const views = ['view-list', 'view-pie', 'view-bar'];
|
||||
views.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
if (id === 'view-' + viewName) {
|
||||
el.classList.remove('d-none');
|
||||
} else {
|
||||
el.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
renderCurrentView();
|
||||
}
|
||||
|
||||
// Function to render current view
|
||||
function renderCurrentView() {
|
||||
if (currentView === 'pie') renderPieChart();
|
||||
else if (currentView === 'bar') renderBarChart();
|
||||
}
|
||||
|
||||
// Helper: Get current question object
|
||||
function getCurrentQuestion() {
|
||||
return questionsData.find(q => q.id === currentQuestionId);
|
||||
}
|
||||
|
||||
// Render Pie Chart
|
||||
function renderPieChart() {
|
||||
const q = getCurrentQuestion();
|
||||
if (!q || !q.stats) return;
|
||||
if (typeof Chart === 'undefined') return;
|
||||
|
||||
const ctx = document.getElementById('pieChart');
|
||||
if (pieChartInstance) pieChartInstance.destroy();
|
||||
|
||||
pieChartInstance = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
{
|
||||
labels: q.stats.map(s => s.label),
|
||||
datasets: [{
|
||||
q.stats.map(s => s.count),
|
||||
backgroundColor: ['#4e73df', '#1cc88a', '#36b9cc', '#f6c23e', '#e74a3b']
|
||||
}]
|
||||
},
|
||||
options: { responsive: true }
|
||||
});
|
||||
}
|
||||
|
||||
// Render Bar Chart
|
||||
function renderBarChart() {
|
||||
const q = getCurrentQuestion();
|
||||
if (!q || !q.stats) return;
|
||||
if (typeof Chart === 'undefined') return;
|
||||
|
||||
const ctx = document.getElementById('barChart');
|
||||
if (barChartInstance) barChartInstance.destroy();
|
||||
|
||||
barChartInstance = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: q.stats.map(s => s.label),
|
||||
datasets: [{
|
||||
label: 'Votes',
|
||||
q.stats.map(s => s.count),
|
||||
backgroundColor: '#4e73df'
|
||||
}]
|
||||
},
|
||||
options: { responsive: true }
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="dashboard_template" name="Survey Dashboard">
|
||||
<t t-call="web.layout">
|
||||
<t t-set="title">Dashboard: <t t-esc="survey.title"/></t>
|
||||
<!-- Load Chart.js CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
</t>
|
||||
|
||||
<!-- ✅ Data is passed here via data-questions-json attribute -->
|
||||
<div id="dashboard-root"
|
||||
t-att-data-questions-json="questions_json"
|
||||
class="container-fluid py-4">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 border-bottom pb-3">
|
||||
<div>
|
||||
<h2 class="mb-2"><t t-esc="survey.title"/></h2>
|
||||
<span class="badge bg-primary me-2">Responses: <t t-esc="total_responses"/></span>
|
||||
<span class="badge bg-success">Completion: <t t-esc="completion_rate"/>%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons: NO onclick attributes -->
|
||||
<div class="mb-3 d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary active" id="btn-list">
|
||||
<i class="fa fa-list me-1"/> List
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary" id="btn-pie">
|
||||
<i class="fa fa-pie-chart me-1"/> Pie Chart
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary" id="btn-bar">
|
||||
<i class="fa fa-bar-chart me-1"/> Bar Graph
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Question Selector -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Select Question:</label>
|
||||
<select class="form-select" id="question-selector" style="max-width: 600px;">
|
||||
<t t-foreach="questions" t-as="q" t-if="q.get('type') == 'question'">
|
||||
<option t-att-value="q.get('id')">
|
||||
<t t-esc="q.get('title')"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="dashboard-scroll-wrapper" style="max-height: calc(100vh - 250px); overflow-y: auto;">
|
||||
<!-- List View -->
|
||||
<div id="view-list" class="view-section">
|
||||
<t t-foreach="questions" t-as="q">
|
||||
<t t-if="q.get('type') == 'page'">
|
||||
<h3 class="h4 mt-4 mb-3 text-primary border-top pt-3"><t t-esc="q.get('title')"/></h3>
|
||||
</t>
|
||||
<t t-elif="q.get('type') == 'question'">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<strong><t t-esc="q.get('title')"/></strong>
|
||||
<span class="badge bg-secondary"><t t-esc="q.get('qtype')"/></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<t t-if="q.get('stats')">
|
||||
<t t-foreach="q.get('stats')" t-as="stat">
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><t t-esc="stat.get('label')"/></span>
|
||||
<span><t t-esc="stat.get('count')"/> (<t t-esc="stat.get('percent')"/>%)</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 15px;">
|
||||
<div class="progress-bar bg-primary" t-att-style="'width: ' + str(stat.get('percent', 0)) + '%'"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Chart Views -->
|
||||
<div id="view-pie" class="view-section d-none">
|
||||
<div class="card"><div class="card-body"><canvas id="pieChart" style="max-height: 400px;"></canvas></div></div>
|
||||
</div>
|
||||
<div id="view-bar" class="view-section d-none">
|
||||
<div class="card"><div class="card-body"><canvas id="barChart" style="max-height: 400px;"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- 🔹 Server Action: Dynamic URL with selected survey ID -->
|
||||
<record id="action_survey_dashboard_dynamic" model="ir.actions.server">
|
||||
<field name="name">Open Survey Dashboard</field>
|
||||
<field name="model_id" ref="survey.model_survey_survey"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code"><![CDATA[
|
||||
if records:
|
||||
action = {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': f'/survey/dashboard/{records[0].id}',
|
||||
'target': 'self',
|
||||
}
|
||||
else:
|
||||
# Show survey selector - using VALID fields only
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'survey.survey',
|
||||
'view_mode': 'list,form',
|
||||
'name': 'Select a Survey',
|
||||
'domain': [
|
||||
('active', '=', True),
|
||||
('user_input_ids', '!=', False)
|
||||
],
|
||||
'context': {'search_default_finished': 1},
|
||||
'help': "Select an active survey with responses to view its dashboard.",
|
||||
}
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<!-- 🔹 Top-Level Menu: Surveys -->
|
||||
<menuitem id="menu_survey_root" name="Dashboard Surveys" sequence="10" web_icon="survey,static/description/icon.png"/>
|
||||
|
||||
<!-- 🔹 Sub-Menu: Dashboard -->
|
||||
<menuitem id="menu_survey_dashboard"
|
||||
name="📊 Survey Dashboard"
|
||||
parent="menu_survey_root"
|
||||
action="action_survey_dashboard_dynamic"
|
||||
sequence="5"
|
||||
groups="survey.group_survey_manager,survey.group_survey_user"/>
|
||||
|
||||
<!-- 🔹 Optional: Keep "See Results" button on form -->
|
||||
<record id="survey_form_inherit_dashboard" model="ir.ui.view">
|
||||
<field name="name">survey.survey.form.inherit.dashboard</field>
|
||||
<field name="model">survey.survey</field>
|
||||
<field name="inherit_id" ref="survey.survey_survey_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Option A: Add button AFTER existing results button (safer) -->
|
||||
<xpath expr="//button[@name='action_result_survey']" position="after">
|
||||
<button name="%(dashboard_survey.action_survey_dashboard_dynamic)d"
|
||||
type="action"
|
||||
string="📊 Dashboard"
|
||||
class="btn btn-secondary ms-2"
|
||||
icon="fa-chart-bar"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Option B: Replace (uncomment if you want to replace) -->
|
||||
<!--
|
||||
<xpath expr="//button[@name='action_result_survey']" position="replace">
|
||||
<button name="%(dashboard_survey.action_survey_dashboard_dynamic)d"
|
||||
type="action"
|
||||
string="📊 See Dashboard"
|
||||
class="oe_stat_button"
|
||||
icon="fa-dashboard"/>
|
||||
</xpath>
|
||||
-->
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,24 @@
|
||||
<odoo>
|
||||
<data>
|
||||
<!--
|
||||
<template id="listing">
|
||||
<ul>
|
||||
<li t-foreach="objects" t-as="object">
|
||||
<a t-attf-href="#{ root }/objects/#{ object.id }">
|
||||
<t t-esc="object.display_name"/>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template id="object">
|
||||
<h1><t t-esc="object.display_name"/></h1>
|
||||
<dl>
|
||||
<t t-foreach="object._fields" t-as="field">
|
||||
<dt><t t-esc="field"/></dt>
|
||||
<dd><t t-esc="object[field]"/></dd>
|
||||
</t>
|
||||
</dl>
|
||||
</template>
|
||||
-->
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_survey_qa_tree" model="ir.ui.view">
|
||||
<field name="name">survey.user.input.line.tree.qa</field>
|
||||
<field name="model">survey.user_input.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Survey Answers" create="0" edit="0" delete="0">
|
||||
<field name="survey_id"/>
|
||||
<field name="question_id"/>
|
||||
<field name="display_answer"/>
|
||||
<field name="answer_is_correct" widget="boolean_circle"/>
|
||||
<field name="user_input_id" optional="hide"/>
|
||||
<field name="create_date" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
<!-- 🔹 GRAPH VIEW -->
|
||||
<record id="view_survey_qa_graph" model="ir.ui.view">
|
||||
<field name="name">survey.user.input.line.graph.qa</field>
|
||||
<field name="model">survey.user_input.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Survey Q and A Distribution" type="bar">
|
||||
<field name="question_id" type="col"/>
|
||||
<field name="display_answer" type="row"/>
|
||||
<field name="id" type="measure" string="Responses"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- 🔹 PIVOT VIEW -->
|
||||
<record id="view_survey_qa_pivot" model="ir.ui.view">
|
||||
<field name="name">survey.user.input.line.pivot.qa</field>
|
||||
<field name="model">survey.user_input.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Survey QA Breakdown" disable_linking="1">
|
||||
<field name="survey_id" type="col"/>
|
||||
<field name="question_id" type="row"/>
|
||||
<field name="display_answer" type="row"/>
|
||||
<!-- ✅ Same measure, works flawlessly in v19 -->
|
||||
<!-- <field name="response_count" type="measure" string="Count"/>-->
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- 🔹 ACTION & MENU -->
|
||||
<record id="action_survey_qa_dashboard" model="ir.actions.act_window">
|
||||
<field name="name">Survey Q A Dashboard</field>
|
||||
<field name="res_model">survey.user_input.line</field>
|
||||
<field name="view_mode">list,graph,pivot</field>
|
||||
<!-- ✅ FIXED: Filter by completed responses only -->
|
||||
<field name="domain">[('user_input_id.state', '=', 'done'), ('user_input_id.test_entry', '=', False)]</field>
|
||||
<field name="context">{'search_default_group_by_question': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No survey answers yet. Collect completed responses to see QA analytics.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_survey_qa_dashboard"
|
||||
name="Q and A Dashboard"
|
||||
parent="survey.menu_surveys"
|
||||
action="action_survey_qa_dashboard"
|
||||
sequence="50"/>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user