diff --git a/README.md b/README.md
index 8b02383..f36c242 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ addon | version | maintainers | summary
[g2p_registry_g2p_connect_rest_api](g2p_registry_g2p_connect_rest_api/) | 17.0.0.0.0 | | OpenG2P Registry: G2P Connect REST API
[g2p_registry_id_deduplication](g2p_registry_id_deduplication/) | 17.0.0.0.0 | | OpenG2P Registry ID Deduplication
[g2p_social_registry](g2p_social_registry/) | 17.0.0.0.0 | | OpenG2P Social Registry
+[g2p_social_registry_dashboard](g2p_social_registry_dashboard/) | 17.0.0.0.0 | | OpenG2P Social Registry: Dashboard
[g2p_social_registry_model](g2p_social_registry_model/) | 17.0.0.0.0 | | G2P Social Registry: Demo
[g2p_social_registry_proxy_means_test](g2p_social_registry_proxy_means_test/) | 17.0.0.0.0 | | G2P Social Registry: PMT
[g2p_social_registry_theme](g2p_social_registry_theme/) | 17.0.0.0.0 | | OpenG2P Social Registry: Theme
diff --git a/g2p_social_registry_dashboard/README.md b/g2p_social_registry_dashboard/README.md
new file mode 100644
index 0000000..9aefd9d
--- /dev/null
+++ b/g2p_social_registry_dashboard/README.md
@@ -0,0 +1,3 @@
+# OpenG2P Social Registry Dashboard
+
+Refer to https://docs.openg2p.org.
diff --git a/g2p_social_registry_dashboard/__init__.py b/g2p_social_registry_dashboard/__init__.py
new file mode 100644
index 0000000..6c4fc1f
--- /dev/null
+++ b/g2p_social_registry_dashboard/__init__.py
@@ -0,0 +1,170 @@
+# Part of OpenG2P. See LICENSE file for full copyright and licensing details.
+
+from . import models
+
+from odoo import _
+from odoo.exceptions import MissingError
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+def init_materialized_view(env):
+ """
+ Initializes or refreshes the materialized views for the res_partner_dashboard_data.
+ """
+ cr = env.cr
+
+ matviews_to_check = [
+ "g2p_gender_count_view",
+ "g2p_age_distribution_view",
+ "g2p_total_registrants_view",
+ "g2p_sr_dashboard_data",
+ ]
+
+ try:
+ cr.execute(
+ """
+ SELECT matviewname
+ FROM pg_matviews
+ WHERE matviewname IN %s;
+ """,
+ (tuple(matviews_to_check),),
+ )
+
+ existing_views = set([row[0] for row in cr.fetchall()])
+
+ if "g2p_gender_count_view" not in existing_views:
+ gender_query = """
+ CREATE MATERIALIZED VIEW g2p_gender_count_view AS
+ SELECT
+ rp.company_id,
+ gt.code AS gender,
+ COUNT(rp.id) AS gender_count
+ FROM
+ res_partner rp
+ LEFT JOIN
+ gender_type gt ON rp.gender = gt.value
+ WHERE
+ rp.is_registrant = True
+ AND rp.active = True
+ AND rp.is_group = False
+ GROUP BY
+ rp.company_id, gt.code;
+ """
+ cr.execute(gender_query)
+ _logger.info("Created materialized view: g2p_gender_count_view")
+
+ if "g2p_age_distribution_view" not in existing_views:
+ age_distribution_query = """
+ CREATE MATERIALIZED VIEW g2p_age_distribution_view AS
+ SELECT
+ rp.company_id,
+ jsonb_build_object(
+ 'below_18', COUNT(rp.id) FILTER (
+ WHERE EXTRACT(YEAR FROM AGE(rp.birthdate)) < 18
+ ),
+ '18_to_30', COUNT(rp.id) FILTER (
+ WHERE EXTRACT(YEAR FROM AGE(rp.birthdate)) BETWEEN 18 AND 30
+ ),
+ '31_to_40', COUNT(rp.id) FILTER (
+ WHERE EXTRACT(YEAR FROM AGE(rp.birthdate)) BETWEEN 31 AND 40
+ ),
+ '41_to_50', COUNT(rp.id) FILTER (
+ WHERE EXTRACT(YEAR FROM AGE(rp.birthdate)) BETWEEN 41 AND 50
+ ),
+ 'above_50', COUNT(rp.id) FILTER (
+ WHERE EXTRACT(YEAR FROM AGE(rp.birthdate)) > 50
+ )
+ ) AS age_distribution
+ FROM
+ res_partner rp
+ WHERE
+ rp.is_registrant = True
+ AND rp.active = True
+ AND rp.is_group = False
+ GROUP BY
+ rp.company_id;
+ """
+ cr.execute(age_distribution_query)
+ _logger.info("Created materialized view: g2p_age_distribution_view")
+
+ if "g2p_total_registrants_view" not in existing_views:
+ total_registrants_query = """
+ CREATE MATERIALIZED VIEW g2p_total_registrants_view AS
+ SELECT
+ rp.company_id,
+ jsonb_build_object(
+ 'total_individuals', COUNT(rp.id) FILTER (WHERE rp.is_group = False),
+ 'total_groups', COUNT(rp.id) FILTER (WHERE rp.is_group = True)
+ ) AS total_registrants
+ FROM
+ res_partner rp
+ WHERE
+ rp.is_registrant = True
+ AND rp.active = True
+ GROUP BY
+ rp.company_id;
+ """
+ cr.execute(total_registrants_query)
+ _logger.info("Created materialized view: g2p_total_registrants_view")
+
+ if "g2p_sr_dashboard_data" not in existing_views:
+ dashboard_query = """
+ CREATE MATERIALIZED VIEW g2p_sr_dashboard_data AS
+ SELECT
+ trv.company_id,
+ trv.total_registrants,
+ COALESCE(
+ jsonb_object_agg(gc.gender, gc.gender_count) FILTER (WHERE gc.gender IS NOT NULL),
+ '{}'
+ ) AS gender_spec,
+ adv.age_distribution
+ FROM
+ g2p_total_registrants_view trv
+ LEFT JOIN
+ g2p_gender_count_view gc ON trv.company_id = gc.company_id
+ LEFT JOIN
+ g2p_age_distribution_view adv ON trv.company_id = adv.company_id
+ GROUP BY
+ trv.company_id, trv.total_registrants, adv.age_distribution;
+ """
+ cr.execute(dashboard_query)
+ _logger.info("Created materialized view: g2p_sr_dashboard_data")
+
+ except Exception as exc:
+ _logger.error("Error while creating materialized views: %s", str(exc))
+ raise MissingError(
+ _(
+ "Failed to create the materialized views."
+ "Please check the logs for details or Manually create it."
+ )
+ ) from exc
+
+
+def drop_materialized_view(env):
+ """
+ Drop all the materialized views related to the dashboard.
+ """
+ cr = env.cr
+
+ matviews_to_drop = [
+ "g2p_sr_dashboard_data",
+ "g2p_gender_count_view",
+ "g2p_age_distribution_view",
+ "g2p_total_registrants_view",
+ ]
+
+ try:
+ for matview in matviews_to_drop:
+ cr.execute(f"DROP MATERIALIZED VIEW IF EXISTS {matview} CASCADE;") # pylint: disable=sql-injection
+ _logger.info("Dropped materialized view: %s", matview)
+
+ except Exception as exc:
+ _logger.error("Error while dropping materialized views: %s", str(exc))
+ raise MissingError(
+ _(
+ "Failed to drop the materialized views."
+ "Please check the logs for details or manually delete the view."
+ )
+ ) from exc
diff --git a/g2p_social_registry_dashboard/__manifest__.py b/g2p_social_registry_dashboard/__manifest__.py
new file mode 100644
index 0000000..3c10c99
--- /dev/null
+++ b/g2p_social_registry_dashboard/__manifest__.py
@@ -0,0 +1,28 @@
+# Part of OpenG2P Registry. See LICENSE file for full copyright and licensing details.
+{
+ "name": "OpenG2P Social Registry: Dashboard",
+ "category": "G2P",
+ "version": "17.0.0.0.0",
+ "sequence": 1,
+ "author": "OpenG2P",
+ "website": "https://openg2p.org",
+ "license": "LGPL-3",
+ "depends": ["g2p_social_registry"],
+ "external_dependencies": {},
+ "data": ["data/cron_job.xml", "views/menu.xml"],
+ "assets": {
+ "web.assets_backend": [
+ "g2p_social_registry_dashboard/static/src/components/chart/**/*",
+ "g2p_social_registry_dashboard/static/src/components/kpi/**/*",
+ "g2p_social_registry_dashboard/static/src/js/dashboard.js",
+ "g2p_social_registry_dashboard/static/src/xml/dashboard.xml",
+ ],
+ },
+ "demo": [],
+ "images": [],
+ "application": True,
+ "installable": True,
+ "auto_install": False,
+ "post_init_hook": "init_materialized_view",
+ "uninstall_hook": "drop_materialized_view",
+}
diff --git a/g2p_social_registry_dashboard/data/cron_job.xml b/g2p_social_registry_dashboard/data/cron_job.xml
new file mode 100644
index 0000000..0ab7fad
--- /dev/null
+++ b/g2p_social_registry_dashboard/data/cron_job.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ Refresh Materialized View Cron Job
+
+ code
+ model._refresh_dashboard_materialized_view()
+ 10
+ hours
+ -1
+
+
+
+
diff --git a/g2p_social_registry_dashboard/models/__init__.py b/g2p_social_registry_dashboard/models/__init__.py
new file mode 100644
index 0000000..84d1d85
--- /dev/null
+++ b/g2p_social_registry_dashboard/models/__init__.py
@@ -0,0 +1,4 @@
+# Part of OpenG2P. See LICENSE file for full copyright and licensing details.
+
+from . import cron
+from . import registrant
diff --git a/g2p_social_registry_dashboard/models/cron.py b/g2p_social_registry_dashboard/models/cron.py
new file mode 100644
index 0000000..7c1445a
--- /dev/null
+++ b/g2p_social_registry_dashboard/models/cron.py
@@ -0,0 +1,47 @@
+import logging
+
+from odoo import _, models
+from odoo.exceptions import MissingError
+
+_logger = logging.getLogger(__name__)
+
+
+class DashboardCron(models.Model):
+ _inherit = "ir.cron"
+
+ def _refresh_dashboard_materialized_view(self):
+ """
+ Refreshes all the materialized views related to the Dashboard.
+ """
+ cr = self.env.cr
+ matviews_to_refresh = [
+ "g2p_gender_count_view",
+ "g2p_age_distribution_view",
+ "g2p_total_registrants_view",
+ "g2p_sr_dashboard_data",
+ ]
+
+ for matview in matviews_to_refresh:
+ try:
+ cr.execute(
+ """
+ SELECT matviewname
+ FROM pg_matviews
+ WHERE matviewname = %s;
+ """,
+ (matview,),
+ )
+
+ if not cr.fetchall():
+ raise MissingError(
+ _("Materialized view '%s' does not exist. Please create it first.") % matview
+ )
+
+ cr.execute(f"REFRESH MATERIALIZED VIEW {matview}") # pylint: disable=sql-injection
+
+ except Exception as exc:
+ _logger.error("Error refreshing materialized view '%s': %s", matview, str(exc))
+ raise MissingError(
+ _("Failed to refresh materialized view '%s'. Please check the logs for details.")
+ % matview
+ ) from exc
diff --git a/g2p_social_registry_dashboard/models/registrant.py b/g2p_social_registry_dashboard/models/registrant.py
new file mode 100644
index 0000000..0a4b607
--- /dev/null
+++ b/g2p_social_registry_dashboard/models/registrant.py
@@ -0,0 +1,35 @@
+# Part of OpenG2P. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models
+
+
+class ResPartnerDashboard(models.Model):
+ _inherit = "res.partner"
+
+ @api.model
+ def get_dashboard_data(self):
+ """Fetch data from materialized view and prepare it for charts."""
+ company_id = self.env.company.id
+
+ query = """
+ SELECT total_registrants, gender_spec, age_distribution
+ FROM g2p_sr_dashboard_data
+ WHERE company_id = %s
+ """
+ self.env.cr.execute(query, (company_id,))
+ result = self.env.cr.fetchone()
+
+ total_registrants, gender_spec, age_distribution = result
+
+ return {
+ "total_individuals": total_registrants.get("total_individuals", 0),
+ "total_groups": total_registrants.get("total_groups", 0),
+ "gender_distribution": gender_spec,
+ "age_distribution": {
+ "Below 18": age_distribution.get("below_18", 0),
+ "18 to 30": age_distribution.get("18_to_30", 0),
+ "31 to 40": age_distribution.get("31_to_40", 0),
+ "41 to 50": age_distribution.get("41_to_50", 0),
+ "Above 50": age_distribution.get("above_50", 0),
+ },
+ }
diff --git a/g2p_social_registry_dashboard/pyproject.toml b/g2p_social_registry_dashboard/pyproject.toml
new file mode 100644
index 0000000..4231d0c
--- /dev/null
+++ b/g2p_social_registry_dashboard/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["whool"]
+build-backend = "whool.buildapi"
diff --git a/g2p_social_registry_dashboard/static/description/icon.png b/g2p_social_registry_dashboard/static/description/icon.png
new file mode 100644
index 0000000..5ecb429
Binary files /dev/null and b/g2p_social_registry_dashboard/static/description/icon.png differ
diff --git a/g2p_social_registry_dashboard/static/src/components/chart/chart.js b/g2p_social_registry_dashboard/static/src/components/chart/chart.js
new file mode 100644
index 0000000..bffafc7
--- /dev/null
+++ b/g2p_social_registry_dashboard/static/src/components/chart/chart.js
@@ -0,0 +1,50 @@
+/** @odoo-module */
+/* global Chart */
+
+import {Component, onMounted, onWillStart, useRef} from "@odoo/owl";
+import {loadJS} from "@web/core/assets";
+
+export class ChartComponent extends Component {
+ setup() {
+ this.canvasRef = useRef("canvas");
+
+ onWillStart(async () => {
+ await loadJS("https://cdn.jsdelivr.net/npm/chart.js");
+ });
+
+ onMounted(() => this.renderChart());
+ }
+
+ renderChart() {
+ const ctx = this.canvasRef.el.getContext("2d");
+
+ // eslint-disable-next-line no-new
+ new Chart(ctx, {
+ type: this.props.type,
+ data: {
+ labels: this.props.labels,
+ datasets: [
+ {
+ label: this.props.data_label,
+ data: this.props.data,
+ backgroundColor: this.props.backgroundColor,
+ hoverOffset: 2,
+ },
+ ],
+ },
+ options: {...this.props.options},
+ });
+ }
+}
+
+ChartComponent.template = "g2p_social_registry_dashboard.ChartTemplate";
+
+ChartComponent.props = {
+ type: {type: String, optional: true},
+ labels: {type: Array, optional: true},
+ data_label: {type: String, optional: true},
+ data: {type: Array, optional: true},
+ backgroundColor: {type: Array, optional: true},
+ options: {type: Object, optional: true},
+ size: {type: String, optional: true},
+};
diff --git a/g2p_social_registry_dashboard/static/src/components/chart/chart.xml b/g2p_social_registry_dashboard/static/src/components/chart/chart.xml
new file mode 100644
index 0000000..4b82f5a
--- /dev/null
+++ b/g2p_social_registry_dashboard/static/src/components/chart/chart.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/g2p_social_registry_dashboard/static/src/components/kpi/kpi.js b/g2p_social_registry_dashboard/static/src/components/kpi/kpi.js
new file mode 100644
index 0000000..bfe8281
--- /dev/null
+++ b/g2p_social_registry_dashboard/static/src/components/kpi/kpi.js
@@ -0,0 +1,13 @@
+/** @odoo-module */
+
+import {Component} from "@odoo/owl";
+
+export class KpiComponent extends Component {}
+
+KpiComponent.template = "g2p_social_registry_dashboard.KpiTemplate";
+
+KpiComponent.props = {
+ title: {type: String, optional: true},
+ data: {type: [String, Number], optional: true},
+ icon_class: {type: String, optional: true},
+};
diff --git a/g2p_social_registry_dashboard/static/src/components/kpi/kpi.xml b/g2p_social_registry_dashboard/static/src/components/kpi/kpi.xml
new file mode 100644
index 0000000..b725b76
--- /dev/null
+++ b/g2p_social_registry_dashboard/static/src/components/kpi/kpi.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
diff --git a/g2p_social_registry_dashboard/static/src/js/dashboard.js b/g2p_social_registry_dashboard/static/src/js/dashboard.js
new file mode 100644
index 0000000..7d81161
--- /dev/null
+++ b/g2p_social_registry_dashboard/static/src/js/dashboard.js
@@ -0,0 +1,49 @@
+/** @odoo-module **/
+import {Component, useState} from "@odoo/owl";
+import {ChartComponent} from "../components/chart/chart";
+import {KpiComponent} from "../components/kpi/kpi";
+import {registry} from "@web/core/registry";
+import {useService} from "@web/core/utils/hooks";
+
+class SRDashboard extends Component {
+ setup() {
+ super.setup();
+ this.orm = useService("orm");
+ this.dashboard_title = "SR Dashboard";
+
+ this.dashboard_data = useState({
+ total_groups: 0,
+ total_individuals: 0,
+ gender_distribution_keys: [],
+ gender_distribution_values: [],
+ age_distribution_keys: [],
+ age_distribution_values: [],
+ });
+
+ this.dataLoaded = useState({flag: false});
+
+ this.fetchData();
+ }
+
+ async fetchData() {
+ try {
+ const data = await this.orm.call("res.partner", "get_dashboard_data", []);
+
+ data.gender_distribution_keys = Object.keys(data.gender_distribution);
+ data.gender_distribution_values = Object.values(data.gender_distribution);
+ data.age_distribution_keys = Object.keys(data.age_distribution);
+ data.age_distribution_values = Object.values(data.age_distribution);
+
+ Object.assign(this.dashboard_data, data);
+
+ this.dataLoaded.flag = true;
+ } catch (error) {
+ console.error("Error fetching dashboard data:", error);
+ }
+ }
+}
+
+SRDashboard.template = "g2p_social_registry_dashboard.dashboard_template";
+SRDashboard.components = {ChartComponent, KpiComponent};
+
+registry.category("actions").add("g2p_social_registry_dashboard.sr_dashboard_tag", SRDashboard);
diff --git a/g2p_social_registry_dashboard/static/src/xml/dashboard.xml b/g2p_social_registry_dashboard/static/src/xml/dashboard.xml
new file mode 100644
index 0000000..09f7851
--- /dev/null
+++ b/g2p_social_registry_dashboard/static/src/xml/dashboard.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/g2p_social_registry_dashboard/views/menu.xml b/g2p_social_registry_dashboard/views/menu.xml
new file mode 100644
index 0000000..db56c79
--- /dev/null
+++ b/g2p_social_registry_dashboard/views/menu.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Dashboard
+ g2p_social_registry_dashboard.sr_dashboard_tag
+
+
+
+
+