diff --git a/addons/education_base/data/education_base_menu.xml b/addons/education_base/data/education_base_menu.xml index 4009cd0..fc1a52b 100644 --- a/addons/education_base/data/education_base_menu.xml +++ b/addons/education_base/data/education_base_menu.xml @@ -1,6 +1,6 @@ - - + + diff --git a/addons/education_base/views/academic_year_views.xml b/addons/education_base/views/academic_year_views.xml index 4a7d3c7..2c8cc60 100644 --- a/addons/education_base/views/academic_year_views.xml +++ b/addons/education_base/views/academic_year_views.xml @@ -4,12 +4,18 @@ education.academic.year.tree education.academic.year - + - + diff --git a/addons/education_dashboard/controllers/__pycache__/main.cpython-312.pyc b/addons/education_dashboard/controllers/__pycache__/main.cpython-312.pyc index 0791b0f..808b76d 100644 Binary files a/addons/education_dashboard/controllers/__pycache__/main.cpython-312.pyc and b/addons/education_dashboard/controllers/__pycache__/main.cpython-312.pyc differ diff --git a/addons/education_dashboard/controllers/main.py b/addons/education_dashboard/controllers/main.py index 0337102..3177f77 100644 --- a/addons/education_dashboard/controllers/main.py +++ b/addons/education_dashboard/controllers/main.py @@ -1,7 +1,8 @@ # pyrefly: ignore [missing-import] -from odoo import http +from odoo import http, fields # pyrefly: ignore [missing-import] from odoo.http import request +import datetime class EducationDashboardController(http.Controller): @@ -9,96 +10,152 @@ class EducationDashboardController(http.Controller): @http.route(['/web/education/dashboard/kpis'], type='json', auth="user") def get_dashboard_kpis(self, campus_id=None, program_id=None, academic_year_id=None, semester_id=None, env=None, **kw): env = env or request.env - # Base Student Domain + + # ── Filters ────────────────────────────────────────────────────────── student_domain = [] - payment_domain = [('status', '=', 'posted')] - - # Apply Filters if campus_id: student_domain.append(('campus_id', '=', int(campus_id))) - payment_domain.append(('student_id.campus_id', '=', int(campus_id))) if program_id: student_domain.append(('program_id', '=', int(program_id))) - payment_domain.append(('student_id.program_id', '=', int(program_id))) if academic_year_id: student_domain.append(('academic_year_id', '=', int(academic_year_id))) - payment_domain.append(('student_id.academic_year_id', '=', int(academic_year_id))) if semester_id: student_domain.append(('semester_id', '=', int(semester_id))) - payment_domain.append(('student_id.semester_id', '=', int(semester_id))) - # Fetch Student Records + # ── Students ───────────────────────────────────────────────────────── students = env['education.student'].search(student_domain) - - # Executive KPIs - total_students = len(students) + total_students = len(students) active_students = len(students.filtered(lambda s: s.status == 'active')) - admissions = len(students.filtered(lambda s: s.status == 'admitted')) - graduates = len(students.filtered(lambda s: s.status == 'graduated')) - withdrawn = len(students.filtered(lambda s: s.status == 'withdrawn')) + admissions = len(students.filtered(lambda s: s.status == 'admitted')) + graduates = len(students.filtered(lambda s: s.status == 'graduated')) - # Finance KPIs - total_fees = sum(students.mapped('total_fees')) - collected_fees = sum(students.mapped('total_paid')) + # ── Finance ─────────────────────────────────────────────────────────── + total_fees = sum(students.mapped('total_fees')) + collected_fees = sum(students.mapped('total_paid')) outstanding_fees = sum(students.mapped('outstanding_amount')) + fee_percent = int(collected_fees / total_fees * 100) if total_fees > 0 else 0 - # Academic KPIs + # ── Academic counts ─────────────────────────────────────────────────── programs_count = env['education.program'].search_count([]) - dept_count = env['education.department'].search_count([]) - campus_count = env['education.campus'].search_count([]) - years_count = env['education.academic.year'].search_count([]) + dept_count = env['education.department'].search_count([]) + campus_count = env['education.campus'].search_count([]) + years_count = env['education.academic.year'].search_count([]) - # Charts: Program Distribution (using read_group for speed) - program_data = env['education.student'].read_group( - student_domain, ['program_id'], ['program_id'] - ) + # ── Charts: Program distribution ────────────────────────────────────── program_chart = [] - for gd in program_data: + for gd in env['education.student'].read_group(student_domain, ['program_id'], ['program_id']): if gd.get('program_id'): - program_chart.append({ - 'name': gd['program_id'][1], - 'count': gd['program_id_count'], - }) + program_chart.append({'name': gd['program_id'][1], 'count': gd['program_id_count']}) - # Charts: Campus Distribution - campus_data = env['education.student'].read_group( - student_domain, ['campus_id'], ['campus_id'] - ) + # ── Charts: Campus distribution ─────────────────────────────────────── campus_chart = [] - for gd in campus_data: + for gd in env['education.student'].read_group(student_domain, ['campus_id'], ['campus_id']): if gd.get('campus_id'): - campus_chart.append({ - 'name': gd['campus_id'][1], - 'count': gd['campus_id_count'], - }) + campus_chart.append({'name': gd['campus_id'][1], 'count': gd['campus_id_count']}) - # Master Data lists for Filters - campuses = [{'id': c.id, 'name': c.name} for c in env['education.campus'].search([])] - programs = [{'id': p.id, 'name': p.name} for p in env['education.program'].search([])] - years = [{'id': y.id, 'name': y.name} for y in env['education.academic.year'].search([])] + # ── Monthly enrollment trend (current calendar year) ───────────────── + month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + cur_year = datetime.date.today().year + enrollment_trend = [] + cumulative = 0 + for m in range(1, 13): + ny = cur_year if m < 12 else cur_year + 1 + nm = m + 1 if m < 12 else 1 + count = env['education.student'].search_count([ + ('create_date', '>=', f'{cur_year}-{m:02d}-01'), + ('create_date', '<', f'{ny}-{nm:02d}-01'), + ]) + cumulative += count + enrollment_trend.append({ + 'month': month_names[m - 1], + 'new': count, + 'total': cumulative if cumulative else total_students, + }) + + # ── Upcoming exams ──────────────────────────────────────────────────── + upcoming_exams = [] + try: + exams = env['education.exam'].search( + [('state', 'not in', ['cancelled', 'completed', 'done'])], + order='date asc', limit=4 + ) + for e in exams: + upcoming_exams.append({ + 'name': e.name, + 'date': e.date.strftime('%b %d, %Y') if e.date else '', + 'program': e.program_id.name if getattr(e, 'program_id', False) and e.program_id else '', + }) + except Exception: + pass + + # ── Recent activities ───────────────────────────────────────────────── + recent_activities = [] + try: + msgs = env['mail.message'].search([ + ('model', 'in', ['education.student', 'education.exam', 'education.fee.payment']), + ('message_type', 'in', ['email', 'comment']), + ], order='date desc', limit=5) + for msg in msgs: + body = msg.subject or '' + if not body and msg.body: + import re + body = re.sub('<[^>]+>', '', msg.body)[:60] + recent_activities.append({ + 'text': body or 'Activity recorded', + 'time': msg.date.strftime('%I:%M %p') if msg.date else '', + 'model': msg.model or '', + }) + except Exception: + pass + + # ── User / date context ─────────────────────────────────────────────── + user_name = env.user.name + today_str = datetime.date.today().strftime('%A, %b %d, %Y') + current_year_name = '' + try: + cy = env['education.academic.year'].search([('current_year', '=', True)], limit=1) + if cy: + current_year_name = cy.name + except Exception: + pass + + # ── Filter dropdown data ────────────────────────────────────────────── + campuses = [{'id': c.id, 'name': c.name} for c in env['education.campus'].search([])] + programs = [{'id': p.id, 'name': p.name} for p in env['education.program'].search([])] + years = [{'id': y.id, 'name': y.name} for y in env['education.academic.year'].search([])] semesters = [{'id': s.id, 'name': s.name} for s in env['education.semester'].search([])] return { - 'total_students': total_students, + # student KPIs + 'total_students': total_students, 'active_students': active_students, - 'admissions': admissions, - 'graduates': graduates, - 'withdrawn': withdrawn, - - 'total_fees': total_fees, - 'collected_fees': collected_fees, + 'admissions': admissions, + 'graduates': graduates, + # finance + 'total_fees': total_fees, + 'collected_fees': collected_fees, 'outstanding_fees': outstanding_fees, - + 'fee_percent': fee_percent, + # academic 'programs_count': programs_count, - 'dept_count': dept_count, - 'campus_count': campus_count, - 'years_count': years_count, - - 'program_chart': program_chart, - 'campus_chart': campus_chart, - - 'campuses': campuses, - 'programs': programs, - 'years': years, + 'dept_count': dept_count, + 'campus_count': campus_count, + 'years_count': years_count, + # charts + 'program_chart': program_chart, + 'campus_chart': campus_chart, + 'enrollment_trend': enrollment_trend, + # operational + 'upcoming_exams': upcoming_exams, + 'recent_activities': recent_activities, + # context + 'user_name': user_name, + 'today': today_str, + 'current_year_name': current_year_name, + # filter options + 'campuses': campuses, + 'programs': programs, + 'years': years, 'semesters': semesters, } diff --git a/addons/education_dashboard/static/src/js/education_dashboard.js b/addons/education_dashboard/static/src/js/education_dashboard.js index 355a60b..3cce0d8 100644 --- a/addons/education_dashboard/static/src/js/education_dashboard.js +++ b/addons/education_dashboard/static/src/js/education_dashboard.js @@ -6,40 +6,43 @@ import { useService } from "@web/core/utils/hooks"; import { loadJS } from "@web/core/assets"; import { rpc } from "@web/core/network/rpc"; +const CHART_COLORS = ['#1f8f8f', '#0d9488', '#0891b2', '#7c3aed', '#ea580c', '#64748b']; + class EducationDashboard extends Component { setup() { this.actionService = useService("action"); this.state = useState({ kpis: {}, - filters: { - campus_id: "", - program_id: "", - academic_year_id: "", - semester_id: "" - } + filters: { campus_id: "", program_id: "", academic_year_id: "", semester_id: "" } }); - - this.programChartRef = useRef("programChart"); - this.campusChartRef = useRef("campusChart"); + + this.enrollmentChartRef = useRef("enrollmentChart"); + this.distributionChartRef = useRef("distributionChart"); + this.feeDonutChartRef = useRef("feeDonutChart"); onWillStart(async () => { - await loadJS("/web/static/lib/Chart/Chart.js"); + // Try loading Chart.js from multiple known Odoo paths + const paths = [ + "/web/static/lib/Chart/Chart.js", + "/web/static/lib/chart.js/chart.js", + "/web/static/lib/chartjs/chart.js", + ]; + for (const p of paths) { + try { await loadJS(p); break; } catch (_) { /* try next */ } + } await this.loadData(); }); - onMounted(() => { - this.renderCharts(); - }); + onMounted(() => this.renderCharts()); } + // ── Data ────────────────────────────────────────────────────────────────── async loadData() { - // Map empty string filters to null const params = {}; for (const [k, v] of Object.entries(this.state.filters)) { params[k] = v ? parseInt(v) : null; } - const data = await rpc("/web/education/dashboard/kpis", params); - this.state.kpis = data; + this.state.kpis = await rpc("/web/education/dashboard/kpis", params); } async onFilterChange(filterKey, ev) { @@ -48,88 +51,226 @@ class EducationDashboard extends Component { this.renderCharts(); } - renderCharts() { - const pCtx = this.programChartRef.el; - if (pCtx) { - if (this.pChart) this.pChart.destroy(); - const labels = this.state.kpis.program_chart.map(x => x.name); - const data = this.state.kpis.program_chart.map(x => x.count); - this.pChart = new Chart(pCtx, { - type: 'bar', - data: { - labels: labels, - datasets: [{ - label: 'Students Count', - data: data, - backgroundColor: '#4F81BD' - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - scales: { - y: { - beginAtZero: true, - ticks: { stepSize: 1 } - } - } - } - }); - } - - const cCtx = this.campusChartRef.el; - if (cCtx) { - if (this.cChart) this.cChart.destroy(); - const labels = this.state.kpis.campus_chart.map(x => x.name); - const data = this.state.kpis.campus_chart.map(x => x.count); - this.cChart = new Chart(cCtx, { - type: 'doughnut', - data: { - labels: labels, - datasets: [{ - data: data, - backgroundColor: ['#4F81BD', '#C0504D', '#9BBB59', '#8064A2', '#4BACC6'] - }] - }, - options: { - responsive: true, - maintainAspectRatio: false - } - }); - } + // ── Formatting helper ───────────────────────────────────────────────────── + fmt(amount) { + if (!amount) return "₹ 0"; + if (amount >= 10000000) return "₹ " + (amount / 10000000).toFixed(2) + " Cr"; + if (amount >= 100000) return "₹ " + (amount / 100000).toFixed(2) + " L"; + if (amount >= 1000) return "₹ " + (amount / 1000).toFixed(1) + " K"; + return "₹ " + amount; } + // ── Charts ──────────────────────────────────────────────────────────────── + renderCharts() { + if (typeof Chart === "undefined") { + console.warn("[EduDashboard] Chart.js not loaded — charts skipped."); + return; + } + this._renderEnrollment(); + this._renderDistribution(); + this._renderFeeDonut(); + } + + _renderEnrollment() { + const ctx = this.enrollmentChartRef.el; + if (!ctx) return; + if (this._enrollChart) this._enrollChart.destroy(); + + const trend = this.state.kpis.enrollment_trend || []; + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + const labels = trend.length ? trend.map(x => x.month) : months; + const newD = trend.length ? trend.map(x => x.new) : Array(12).fill(0); + const totD = trend.length ? trend.map(x => x.total) : Array(12).fill(0); + + this._enrollChart = new Chart(ctx, { + type: "line", + data: { + labels, + datasets: [ + { + label: "New Admissions", + data: newD, + borderColor: "#0d9488", + backgroundColor: "rgba(13,148,136,0.1)", + borderWidth: 2, + fill: true, + tension: 0.4, + pointBackgroundColor: "#0d9488", + pointRadius: 3, + pointHoverRadius: 5, + }, + { + label: "Total Students", + data: totD, + borderColor: "#1f8f8f", + backgroundColor: "rgba(31,143,143,0.06)", + borderWidth: 2, + fill: true, + tension: 0.4, + pointBackgroundColor: "#1f8f8f", + pointRadius: 3, + pointHoverRadius: 5, + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: "#0f172a", + titleColor: "#fff", + bodyColor: "#94a3b8", + padding: 10, + cornerRadius: 8, + } + }, + scales: { + x: { grid: { display: false }, ticks: { color: "#94a3b8", font: { size: 11 } } }, + y: { + beginAtZero: true, + grid: { color: "#f1f5f9" }, + ticks: { color: "#94a3b8", font: { size: 11 }, stepSize: 1 } + } + } + } + }); + } + + _renderDistribution() { + const ctx = this.distributionChartRef.el; + if (!ctx) return; + if (this._distChart) this._distChart.destroy(); + + const progs = this.state.kpis.program_chart || []; + const labels = progs.map(p => p.name); + const data = progs.map(p => p.count); + + this._distChart = new Chart(ctx, { + type: "doughnut", + data: { + labels, + datasets: [{ + data: data.length ? data : [1], + backgroundColor: data.length ? CHART_COLORS.slice(0, data.length) : ["#e2e8f0"], + borderWidth: 0, + hoverOffset: 4, + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: "60%", + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: "#0f172a", + titleColor: "#fff", + bodyColor: "#94a3b8", + padding: 10, + cornerRadius: 8, + } + } + } + }); + } + + _renderFeeDonut() { + const ctx = this.feeDonutChartRef.el; + if (!ctx) return; + if (this._feeChart) this._feeChart.destroy(); + + const pct = this.state.kpis.fee_percent || 0; + const rest = Math.max(0, 100 - pct); + + this._feeChart = new Chart(ctx, { + type: "doughnut", + data: { + datasets: [{ + data: [pct, rest], + backgroundColor: ["#1f8f8f", "#f1f5f9"], + borderWidth: 0, + }] + }, + options: { + responsive: false, + maintainAspectRatio: false, + cutout: "72%", + plugins: { legend: { display: false }, tooltip: { enabled: false } } + } + }); + } + + // ── Navigation actions ──────────────────────────────────────────────────── openStudents(status) { - const domain = []; - if (status) { - domain.push(['status', '=', status]); - } - if (this.state.filters.campus_id) { - domain.push(['campus_id', '=', parseInt(this.state.filters.campus_id)]); - } - if (this.state.filters.program_id) { - domain.push(['program_id', '=', parseInt(this.state.filters.program_id)]); - } - + const domain = status ? [["status", "=", status]] : []; + if (this.state.filters.campus_id) domain.push(["campus_id", "=", parseInt(this.state.filters.campus_id)]); + if (this.state.filters.program_id) domain.push(["program_id", "=", parseInt(this.state.filters.program_id)]); this.actionService.doAction({ - name: "Students List", - type: "ir.actions.act_window", + name: "Students", type: "ir.actions.act_window", res_model: "education.student", views: [[false, "list"], [false, "form"]], - domain: domain, - target: "current" + domain, target: "current" }); } openPrograms() { this.actionService.doAction({ - name: "Active Programs", - type: "ir.actions.act_window", + name: "Programs", type: "ir.actions.act_window", res_model: "education.program", views: [[false, "list"], [false, "form"]], target: "current" }); } + + openAddStudent() { + this.actionService.doAction({ + name: "New Student", type: "ir.actions.act_window", + res_model: "education.student", + views: [[false, "form"]], target: "current" + }); + } + + openAddFaculty() { + this.actionService.doAction({ + name: "Faculty", type: "ir.actions.act_window", + res_model: "hr.employee", + views: [[false, "list"], [false, "form"]], target: "current" + }); + } + + openCreateCourse() { + this.actionService.doAction({ + name: "Courses", type: "ir.actions.act_window", + res_model: "education.course", + views: [[false, "list"], [false, "form"]], target: "current" + }); + } + + openScheduleExam() { + this.actionService.doAction({ + name: "Examinations", type: "ir.actions.act_window", + res_model: "education.exam", + views: [[false, "list"], [false, "form"]], target: "current" + }); + } + + openAttendance() { + this.actionService.doAction({ + name: "Attendance", type: "ir.actions.act_window", + res_model: "education.attendance", + views: [[false, "list"], [false, "form"]], target: "current" + }); + } + + openFees() { + this.actionService.doAction({ + name: "Fee Payments", type: "ir.actions.act_window", + res_model: "education.fee.payment", + views: [[false, "list"], [false, "form"]], target: "current" + }); + } } EducationDashboard.template = "education_dashboard.DashboardMain"; diff --git a/addons/education_dashboard/static/src/xml/education_dashboard_templates.xml b/addons/education_dashboard/static/src/xml/education_dashboard_templates.xml index ffc32d6..db07d3b 100644 --- a/addons/education_dashboard/static/src/xml/education_dashboard_templates.xml +++ b/addons/education_dashboard/static/src/xml/education_dashboard_templates.xml @@ -1,172 +1,314 @@ - -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
+ +
- -
-
-
-
-
-
-
Total Students
-

-
- -
-
-
-
-
-
-
-
-
-
Active Students
-

-
- -
-
-
-
-
-
-
-
-
-
Admissions
-

-
- -
-
-
-
-
-
-
-
-
-
Graduates
-

-
- -
-
-
-
-
+ - -
-
-
-
Total Fees Assigned
-

-
-
-
-
-
Collected Fees
-

-
-
-
-
-
Outstanding Fees
-

-
-
-
+ +
+
+
+
+
Admin Dashboard
+
Welcome back, !
+
Manage your institution efficiently with real-time insights.
+
+
+
+
+
+
+
+
+
+
- -
-
-
-
Program-wise Distribution
-
- -
-
-
-
-
-
Campus Distribution
-
- -
-
-
-
+ +
+
+
+
+
+
Total Students
+
+
+
+
+
+
+
Active Students
+
+
+
+
+
+
+
Programs
+
+
+
+
+
+
+
Departments
+
+
+
+
+
+
+
Fees Collected
+
+
+
- -
-
-
-
Programs
-

-
-
-
-
-
Departments
-

-
-
-
-
-
Campuses
-

-
-
-
-
-
Academic Years
-

-
-
-
+ +
+ + +
+
+
+ + Student Enrollment Trend +
+ This Academic Year +
+
+
+
+
New Admissions +
+
+
Total Students +
+
+
+ + +
+
+
+ + Student Distribution +
+ By Program +
+
+
+ +
+
+
+ +
+
+
+ +
No program data yet
+
+
+
+ + + +
+ + +
+ + +
+
+
+ + Upcoming Examinations +
+ View All +
+ + +
+
+
+
+
+
+
+
+ +
+
+
+ +
+ +
No upcoming exams scheduled
+
+
+
+ + +
+
+
+ + Fee Collection Overview +
+ This Academic Year +
+
+
+
+
Collected
+
+
+ +
+ +
+
+
+
+
+
+
+
+
Collected
+ +
+
+
Pending
+ +
+
+
Total
+ +
+
+
+ + +
+
+
+ + Announcements +
+ View All +
+
+ Important +
+
Exam Schedule Released
+
Mid-term schedule has been published.
+
+ +
+
+ General +
+
Library Hours Extended
+
Open till 8 PM this week.
+
+ +
+
+ Academic +
+
Course Registration Open
+
Elective course registration is now open.
+
+ +
+
+ Event +
+
Annual Tech Fest
+
Scheduled for next month.
+
+ +
+
+
+ + +
+
+
+ + Recent Activities +
+ View All +
+
+ + +
+
+
+ +
+
+
+
+
+ + +
+
+
+ +
+
+
+
+
+
+
+ +
+
diff --git a/addons/education_dashboard/views/dashboard_views.xml b/addons/education_dashboard/views/dashboard_views.xml index 8c8074a..4e69491 100644 --- a/addons/education_dashboard/views/dashboard_views.xml +++ b/addons/education_dashboard/views/dashboard_views.xml @@ -6,11 +6,15 @@ education_dashboard_main_tag - - + + + + + + + diff --git a/addons/education_portal/views/login_templates.xml b/addons/education_portal/views/login_templates.xml index 5469a00..cb0c898 100644 --- a/addons/education_portal/views/login_templates.xml +++ b/addons/education_portal/views/login_templates.xml @@ -306,7 +306,7 @@