@ -1,47 +0,0 @@ |
|||
.. image:: https://img.shields.io/badge/license-LGPL--3-green.svg |
|||
:target: https://www.gnu.org/licenses/lgpl-3.0-standalone.html |
|||
:alt: License: LGPL-3 |
|||
|
|||
Sales Dashboard Odoo 18 |
|||
=========================== |
|||
Dashboard for Sales module. |
|||
|
|||
Configuration |
|||
============= |
|||
No additional configurations needed. |
|||
|
|||
Company |
|||
------- |
|||
* `Cybrosys Techno Solutions <https://cybrosys.com/>`__ |
|||
|
|||
License |
|||
------- |
|||
Lesser General Public License, Version 3 (LGPL v3) |
|||
(https://www.gnu.org/licenses/lgpl-3.0-standalone.html) |
|||
|
|||
Credits |
|||
------- |
|||
* Developer : (V18) Abhinave M |
|||
Contact: odoo@cybrosys.com |
|||
|
|||
Contacts |
|||
-------- |
|||
* Mail Contact : odoo@cybrosys.com |
|||
* Website : https://cybrosys.com |
|||
|
|||
Bug Tracker |
|||
----------- |
|||
Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. |
|||
|
|||
Maintainer |
|||
========== |
|||
.. image:: https://cybrosys.com/images/logo.png |
|||
:target: https://cybrosys.com |
|||
|
|||
This module is maintained by Cybrosys Technologies. |
|||
|
|||
For support and more information, please visit `Our Website <https://cybrosys.com/>`__ |
|||
|
|||
Further information |
|||
=================== |
|||
HTML Description: `<static/description/index.html>`__ |
|||
@ -1,22 +0,0 @@ |
|||
# -*- coding: utf-8 -*- |
|||
############################################################################# |
|||
# |
|||
# Cybrosys Technologies Pvt. Ltd. |
|||
# |
|||
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
|||
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>) |
|||
# |
|||
# You can modify it under the terms of the GNU LESSER |
|||
# GENERAL PUBLIC LICENSE (LGPL v3), Version 3. |
|||
# |
|||
# This program is distributed in the hope that it will be useful, |
|||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
# GNU LESSER GENERAL PUBLIC LICENSE (LGPL v3) for more details. |
|||
# |
|||
# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE |
|||
# (LGPL v3) along with this program. |
|||
# If not, see <http://www.gnu.org/licenses/>. |
|||
# |
|||
############################################################################# |
|||
from . import models |
|||
@ -1,53 +0,0 @@ |
|||
# -*- coding: utf-8 -*- |
|||
############################################################################# |
|||
# |
|||
# Cybrosys Technologies Pvt. Ltd. |
|||
# |
|||
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
|||
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>) |
|||
# |
|||
# You can modify it under the terms of the GNU LESSER |
|||
# GENERAL PUBLIC LICENSE (LGPL v3), Version 3. |
|||
# |
|||
# This program is distributed in the hope that it will be useful, |
|||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
# GNU LESSER GENERAL PUBLIC LICENSE (LGPL v3) for more details. |
|||
# |
|||
# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE |
|||
# (LGPL v3) along with this program. |
|||
# If not, see <http://www.gnu.org/licenses/>. |
|||
# |
|||
############################################################################# |
|||
{ |
|||
'name': "Sales Dashboard", |
|||
'version': '18.0.1.0.0', |
|||
'category': 'Sales', |
|||
'summary': 'Detailed dashboard view for Sales module.', |
|||
'description': """This module provides a comprehensive dashboard for the Sales module, |
|||
offering a clear and actionable overview of orders, quotations, invoicing status, |
|||
revenues, and customer behavior. It enables sales teams and managers to monitor performance, |
|||
identify trends, and make informed decisions with real-time data visibility.""", |
|||
'author': "Cybrosys Techno Solutions", |
|||
'company': 'Cybrosys Techno Solutions', |
|||
'maintainer': 'Cybrosys Techno Solutions', |
|||
'website': "https://www.cybrosys.com", |
|||
'depends': ['base', 'sale_management', 'web'], |
|||
'data': [ |
|||
'views/sales_dashboard_views.xml', |
|||
], |
|||
'assets': { |
|||
'web.assets_backend': [ |
|||
'sales_dashboard/static/src/js/sales_dashboard.js', |
|||
'sales_dashboard/static/src/xml/sales_dashboard.xml', |
|||
'https://cdn.jsdelivr.net/npm/chart.js', |
|||
], |
|||
}, |
|||
'images': [ |
|||
'static/description/banner.jpg', |
|||
], |
|||
'license': 'LGPL-3', |
|||
'installable': True, |
|||
'auto_install': False, |
|||
'application': False, |
|||
} |
|||
@ -1,22 +0,0 @@ |
|||
# -*- coding: utf-8 -*- |
|||
############################################################################# |
|||
# |
|||
# Cybrosys Technologies Pvt. Ltd. |
|||
# |
|||
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
|||
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>) |
|||
# |
|||
# You can modify it under the terms of the GNU LESSER |
|||
# GENERAL PUBLIC LICENSE (LGPL v3), Version 3. |
|||
# |
|||
# This program is distributed in the hope that it will be useful, |
|||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
# GNU LESSER GENERAL PUBLIC LICENSE (LGPL v3) for more details. |
|||
# |
|||
# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE |
|||
# (LGPL v3) along with this program. |
|||
# If not, see <http://www.gnu.org/licenses/>. |
|||
# |
|||
############################################################################# |
|||
from . import sale_order |
|||
@ -1,345 +0,0 @@ |
|||
# -*- coding: utf-8 -*- |
|||
############################################################################# |
|||
# |
|||
# Cybrosys Technologies Pvt. Ltd. |
|||
# |
|||
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
|||
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>) |
|||
# |
|||
# You can modify it under the terms of the GNU LESSER |
|||
# GENERAL PUBLIC LICENSE (LGPL v3), Version 3. |
|||
# |
|||
# This program is distributed in the hope that it will be useful, |
|||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
# GNU LESSER GENERAL PUBLIC LICENSE (LGPL v3) for more details. |
|||
# |
|||
# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE |
|||
# (LGPL v3) along with this program. |
|||
# If not, see <http://www.gnu.org/licenses/>. |
|||
# |
|||
############################################################################# |
|||
from odoo import models, api, fields |
|||
from datetime import date, timedelta |
|||
from odoo.exceptions import UserError |
|||
|
|||
|
|||
class SaleOrder(models.Model): |
|||
"""Extends sale.order model to fetch data to be displayed in the dashboard""" |
|||
_inherit = 'sale.order' |
|||
|
|||
def _get_range(self, filter_key, custom_start=None, custom_end=None): |
|||
"""Manages the date range according to the filter selected""" |
|||
today = date.today() |
|||
|
|||
if filter_key == 'this_week': |
|||
start = today - timedelta(days=today.weekday()) |
|||
end = start + timedelta(days=6) |
|||
return start, min(end, today) |
|||
|
|||
elif filter_key == 'this_month': |
|||
return today.replace(day=1), today |
|||
|
|||
elif filter_key == 'this_year': |
|||
return today.replace(month=1, day=1), today |
|||
|
|||
elif filter_key == 'custom' and custom_start and custom_end: |
|||
if custom_end < custom_start: |
|||
raise UserError("Please select a valid range") |
|||
return custom_start, custom_end |
|||
|
|||
return None, None |
|||
|
|||
def _build_global_domain(self, base_domain, filters, date_field="date_order"): |
|||
"""Build domain with global filter applied""" |
|||
global_filter = filters.get("global_filter", "this_week") |
|||
if global_filter == "select_period": |
|||
global_filter = "this_week" |
|||
|
|||
if global_filter == "custom": |
|||
custom_range = filters.get("custom_range", {}) |
|||
custom_start = custom_range.get("from") |
|||
custom_end = custom_range.get("to") |
|||
else: |
|||
custom_start = None |
|||
custom_end = None |
|||
|
|||
from_date, to_date = self._get_range(global_filter, custom_start, custom_end) |
|||
|
|||
domain = base_domain.copy() |
|||
if from_date: |
|||
domain.append((date_field, '>=', from_date)) |
|||
if to_date: |
|||
domain.append((date_field, '<=', to_date)) |
|||
return domain |
|||
|
|||
@api.model |
|||
def get_tile_domain(self, base_domain, filters): |
|||
"""Get domain for tile clicks with global filter applied""" |
|||
filterss = self._build_global_domain(base_domain, filters) |
|||
return self._build_global_domain(base_domain, filters) |
|||
|
|||
@api.model |
|||
def get_sales_dashboard_data(self, filters=None): |
|||
"""Fetches the datas to be displayed""" |
|||
filters = filters or {} |
|||
limit = int(filters.get("limit", 10)) |
|||
|
|||
def get_filter_key(specific_filter_key): |
|||
"""Build and return a domain combining base_domain with global date filters.""" |
|||
global_filter = filters.get("global_filter", "this_week") |
|||
if global_filter == "custom": |
|||
return "custom" |
|||
elif global_filter == "select_period": |
|||
return filters.get(specific_filter_key, "this_week") |
|||
else: |
|||
return global_filter |
|||
|
|||
def build_domain(base_domain, specific_filter_key, date_field="date_order"): |
|||
"""Attach date domain based on the filter key""" |
|||
filter_key = get_filter_key(specific_filter_key) |
|||
|
|||
if filter_key == "custom": |
|||
custom_range = filters.get("custom_range", {}) |
|||
custom_start = custom_range.get("from") |
|||
custom_end = custom_range.get("to") |
|||
else: |
|||
custom_start = None |
|||
custom_end = None |
|||
|
|||
from_date, to_date = self._get_range(filter_key, custom_start, custom_end) |
|||
|
|||
domain = base_domain.copy() |
|||
if from_date: |
|||
domain.append((date_field, '>=', from_date)) |
|||
if to_date: |
|||
domain.append((date_field, '<=', to_date)) |
|||
return domain |
|||
|
|||
team_domain = build_domain([('state', 'in', ['sale', 'done'])], "team_filter") |
|||
sales_by_team = self.read_group(team_domain, ['amount_total'], ['team_id'], limit=limit, orderby='amount_total desc') |
|||
teams = [ |
|||
{'id': rec['team_id'][0], 'name': rec['team_id'][1], 'amount': rec['amount_total']} |
|||
for rec in sales_by_team if rec['team_id'] |
|||
] |
|||
|
|||
person_domain = build_domain([('state', 'in', ['sale', 'done'])], "person_filter") |
|||
sales_by_person = self.read_group(person_domain, ['amount_total'], ['user_id'], limit=limit, orderby='amount_total desc') |
|||
persons = [ |
|||
{'id': rec['user_id'][0], 'name': rec['user_id'][1], 'amount': rec['amount_total']} |
|||
for rec in sales_by_person if rec['user_id'] |
|||
] |
|||
|
|||
customer_domain = build_domain([('state', 'in', ['sale', 'done'])], "customer_filter") |
|||
customers_grouped = self.read_group(customer_domain, ['amount_total'], ['partner_id'], |
|||
limit=limit, orderby='amount_total desc') |
|||
customers = [ |
|||
{'id': rec['partner_id'][0], 'name': rec['partner_id'][1], 'amount': rec['amount_total']} |
|||
for rec in customers_grouped if rec['partner_id'] |
|||
] |
|||
|
|||
product_domain = build_domain([('order_id.state', 'in', ['sale', 'done'])], |
|||
"product_filter", "order_id.date_order") |
|||
if filters.get("product_category_id"): |
|||
product_domain.append(('product_id.categ_id', '=', filters["product_category_id"])) |
|||
top_products_grouped = self.env['sale.order.line'].read_group( |
|||
product_domain, ['product_uom_qty'], ['product_id'], |
|||
limit=limit, orderby='product_uom_qty desc' |
|||
) |
|||
top_products = [ |
|||
{'id': rec['product_id'][0], 'name': rec['product_id'][1], 'qty': rec['product_uom_qty']} |
|||
for rec in top_products_grouped if rec['product_id'] |
|||
] |
|||
|
|||
low_product_domain = build_domain([('order_id.state', 'in', ['sale', 'done'])], |
|||
"low_product_filter", "order_id.date_order") |
|||
if filters.get("low_product_category_id"): |
|||
low_product_domain.append(('product_id.categ_id', '=', filters["low_product_category_id"])) |
|||
low_products_grouped = self.env['sale.order.line'].read_group( |
|||
low_product_domain, ['product_uom_qty'], ['product_id'], |
|||
limit=limit, orderby='product_uom_qty asc' |
|||
) |
|||
low_products = [ |
|||
{'id': rec['product_id'][0], 'name': rec['product_id'][1], 'qty': rec['product_uom_qty']} |
|||
for rec in low_products_grouped if rec['product_id'] |
|||
] |
|||
|
|||
order_domain = build_domain([], "order_filter") |
|||
order_status_grouped = self.read_group(order_domain, ['id'], ['state']) |
|||
ORDER_STATUS_LABELS = { |
|||
'draft': 'Quotation', |
|||
'sent': 'Quotation Sent', |
|||
'sale': 'Sales Order', |
|||
'done': 'Locked', |
|||
'cancel': 'Cancelled', |
|||
} |
|||
order_status = [ |
|||
{'status': ORDER_STATUS_LABELS.get(rec['state'], rec['state'].capitalize()), |
|||
'count': rec['state_count']} |
|||
for rec in order_status_grouped |
|||
] |
|||
|
|||
invoice_domain = build_domain([('move_type', '=', 'out_invoice')], "invoice_filter", "invoice_date") |
|||
invoice_status_grouped = self.env['account.move'].read_group(invoice_domain, ['id'], ['state']) |
|||
INVOICE_STATUS_LABELS = { |
|||
'draft': 'Draft', |
|||
'posted': 'Posted', |
|||
'cancel': 'Cancelled', |
|||
} |
|||
invoice_status = [ |
|||
{'status': INVOICE_STATUS_LABELS.get(rec['state'], rec['state'].capitalize()), |
|||
'count': rec['state_count']} |
|||
for rec in invoice_status_grouped |
|||
] |
|||
|
|||
overdue_customers_domain =build_domain([ |
|||
('move_type', '=', 'out_invoice'), |
|||
('payment_state', '!=', 'paid'), |
|||
('invoice_date_due', '<', fields.Date.today()) |
|||
],"overdue_filter","invoice_date") |
|||
overdue_customers_grouped = self.env['account.move'].read_group( |
|||
overdue_customers_domain, ['amount_total'], ['partner_id'], orderby='amount_total desc', limit=limit |
|||
) |
|||
overdue_customers =[ |
|||
{'id': rec['partner_id'][0], 'name': rec['partner_id'][1], 'amount': rec['amount_total']} |
|||
for rec in overdue_customers_grouped if rec['partner_id'] |
|||
] |
|||
|
|||
categories = self.env['product.category'].search([]) |
|||
product_categories = [{'id': c.id, 'name': c.display_name} for c in categories] |
|||
|
|||
sale_orders_domain = build_domain([('state', 'in', ['sale', 'done'])], "order_tile_filter") |
|||
sale_orders = self.search_count(sale_orders_domain) |
|||
|
|||
quotations_domain = build_domain([('state', 'in', ['draft', 'sent'])], "quotation_tile_filter") |
|||
quotations = self.search_count(quotations_domain) |
|||
|
|||
orders_to_invoice_domain = build_domain([('invoice_status', '=', 'to invoice')], "to_invoice_tile_filter") |
|||
orders_to_invoice = self.search_count(orders_to_invoice_domain) |
|||
|
|||
orders_fully_invoiced_domain = build_domain([('invoice_status', '=', 'invoiced')], "invoiced_tile_filter") |
|||
orders_fully_invoiced = self.search_count(orders_fully_invoiced_domain) |
|||
|
|||
conversion_rate = round( |
|||
(sale_orders / (quotations + sale_orders) * 100) if (quotations + sale_orders) > 0 else 0) |
|||
|
|||
SaleOrder = self.env['sale.order'] |
|||
company_currency = self.env.company.currency_id |
|||
today = fields.Date.today() |
|||
month_start = today.replace(day=1) |
|||
year_start = today.replace(month=1, day=1) |
|||
|
|||
def _get_total(start_date): |
|||
"""To find the total amount by grouping the orders based on the currency""" |
|||
groups = SaleOrder.read_group( |
|||
[('state', '=', 'sale'), ('date_order', '>=', start_date)], |
|||
['amount_total:sum'], |
|||
['currency_id'] |
|||
) |
|||
total = 0.0 |
|||
for g in groups: |
|||
if g['currency_id']: |
|||
currency = self.env['res.currency'].browse(g['currency_id'][0]) |
|||
total += currency._convert( |
|||
g['amount_total'], |
|||
company_currency, |
|||
self.env.company, |
|||
today |
|||
) |
|||
return total |
|||
|
|||
total_revenue_mtd = _get_total(month_start) |
|||
total_revenue_ytd = _get_total(year_start) |
|||
|
|||
groups = SaleOrder.read_group( |
|||
[('state', '=', 'sale')], |
|||
['amount_total:sum', 'id:count'], |
|||
['currency_id'] |
|||
) |
|||
|
|||
total_revenue = 0.0 |
|||
total_orders = 0 |
|||
for g in groups: |
|||
if g['currency_id']: |
|||
currency = self.env['res.currency'].browse(g['currency_id'][0]) |
|||
total_revenue += currency._convert( |
|||
g['amount_total'], |
|||
company_currency, |
|||
self.env.company, |
|||
today |
|||
) |
|||
total_orders += g.get('currency_id_count', 0) |
|||
|
|||
avg_order_value = total_revenue / total_orders if total_orders else 0 |
|||
|
|||
sales_info = { |
|||
'sale_orders': sale_orders, |
|||
'quotation': quotations, |
|||
'orders_to_invoice': orders_to_invoice, |
|||
'orders_fully_invoiced': orders_fully_invoiced, |
|||
'conversion_rate': conversion_rate, |
|||
'total_revenue_mtd': company_currency.format(total_revenue_mtd), |
|||
'total_revenue_ytd': company_currency.format(total_revenue_ytd), |
|||
'avg_order_value': company_currency.format(avg_order_value), |
|||
} |
|||
|
|||
nvrc_domain = build_domain([('state', 'in', ['sale', 'done'])], "nvrc_filter") |
|||
|
|||
date_from, date_to = None, None |
|||
for d in nvrc_domain: |
|||
if d[0] == 'date_order' and d[1] == '>=': |
|||
date_from = d[2] |
|||
elif d[0] == 'date_order' and d[1] == '<=': |
|||
date_to = d[2] |
|||
|
|||
if date_from and isinstance(date_from, str): |
|||
date_from = fields.Date.from_string(date_from) |
|||
if date_to and isinstance(date_to, str): |
|||
date_to = fields.Date.from_string(date_to) |
|||
|
|||
current_customers = self.read_group( |
|||
nvrc_domain, ['partner_id'], ['partner_id'] |
|||
) |
|||
customer_ids = [rec['partner_id'][0] for rec in current_customers if rec['partner_id']] |
|||
|
|||
new_customers, returning_customers = [], [] |
|||
if customer_ids: |
|||
first_orders = self.read_group( |
|||
[('partner_id', 'in', customer_ids), ('state', 'in', ['sale', 'done'])], |
|||
['partner_id', 'date_order:min'], |
|||
['partner_id'] |
|||
) |
|||
first_order_map = {rec['partner_id'][0]: rec['date_order'] for rec in first_orders if rec['partner_id']} |
|||
|
|||
partners = self.env['res.partner'].browse(customer_ids) |
|||
for partner in partners: |
|||
first_date = first_order_map.get(partner.id) |
|||
if first_date and date_from and first_date.date() >= date_from: |
|||
new_customers.append({'id': partner.id, 'name': partner.display_name}) |
|||
else: |
|||
returning_customers.append({'id': partner.id, 'name': partner.display_name}) |
|||
|
|||
new_vs_returning = { |
|||
'summary': { |
|||
'labels': ["New Customers", "Returning Customers"], |
|||
'values': [len(new_customers), len(returning_customers)], |
|||
}, |
|||
'details': { |
|||
'new': new_customers[:limit], |
|||
'returning': returning_customers[:limit], |
|||
} |
|||
} |
|||
|
|||
return { |
|||
'sales_by_team': teams, |
|||
'sales_by_person': persons, |
|||
'top_customers': customers, |
|||
'top_products': top_products, |
|||
'lowest_products': low_products, |
|||
'overdue_customers': overdue_customers, |
|||
'order_status': order_status, |
|||
'invoice_status': invoice_status, |
|||
'product_categories': product_categories, |
|||
'sales_info': sales_info, |
|||
'new_vs_returning': new_vs_returning, |
|||
} |
|||
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 628 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 495 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 624 B |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 310 B |
|
Before Width: | Height: | Size: 929 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 542 B |
|
Before Width: | Height: | Size: 576 B |
|
Before Width: | Height: | Size: 733 B |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 738 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 911 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 600 B |
|
Before Width: | Height: | Size: 673 B |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 926 B |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 878 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 653 B |
|
Before Width: | Height: | Size: 800 B |
|
Before Width: | Height: | Size: 905 B |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 839 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 427 B |
|
Before Width: | Height: | Size: 627 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 988 B |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 875 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 767 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 760 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 697 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 880 KiB |
|
Before Width: | Height: | Size: 755 KiB |
|
Before Width: | Height: | Size: 42 KiB |
@ -1,337 +0,0 @@ |
|||
/** @odoo-module **/ |
|||
import { registry } from "@web/core/registry"; |
|||
import { useService } from "@web/core/utils/hooks"; |
|||
import { Component, useState, onMounted } from "@odoo/owl"; |
|||
|
|||
const actionRegistry = registry.category("actions"); |
|||
|
|||
|
|||
class SalesDashboard extends Component { |
|||
setup() { |
|||
this.orm = useService("orm"); |
|||
this.actionService = useService('action') |
|||
this.charts = {}; |
|||
this.state = useState({ |
|||
data: { |
|||
sales_info: { |
|||
sale_orders: 0, |
|||
quotation: 0, |
|||
orders_to_invoice: 0, |
|||
orders_fully_invoiced: 0, |
|||
} |
|||
}, |
|||
filters: { |
|||
global_filter: "select_period", |
|||
custom_range: { from: null, to: null }, |
|||
limit: "10", |
|||
team_filter: "this_week", |
|||
person_filter: "this_week", |
|||
product_filter: "this_week", |
|||
low_product_filter: "this_week", |
|||
customer_filter: "this_week", |
|||
order_filter: "this_week", |
|||
invoice_filter: "this_week", |
|||
}, |
|||
}); |
|||
onMounted(() => this._fetch_data()); |
|||
} |
|||
|
|||
async _fetch_data() { |
|||
const result = await this.orm.call( |
|||
"sale.order", |
|||
"get_sales_dashboard_data", |
|||
[], |
|||
{ filters: this.state.filters } |
|||
); |
|||
|
|||
this.state.data = result; |
|||
this._render_charts(); |
|||
} |
|||
|
|||
|
|||
goToRecord(model, id) { |
|||
window.location.href = `/web#model=${model}&id=${id}&view_type=form`; |
|||
} |
|||
async viewOrders(){ |
|||
const domain = await this.orm.call( |
|||
"sale.order", |
|||
"get_tile_domain", |
|||
[], |
|||
{ |
|||
base_domain: [['state', 'in', ['sale', 'done']]], |
|||
filters: this.state.filters |
|||
} |
|||
); |
|||
|
|||
this.actionService.doAction({ |
|||
type: "ir.actions.act_window", |
|||
name: "Sale Orders", |
|||
res_model: "sale.order", |
|||
domain, |
|||
views: [[false, "list"], [false, "form"]] |
|||
}) |
|||
} |
|||
|
|||
async viewQuotations(){ |
|||
const domain = await this.orm.call( |
|||
"sale.order", |
|||
"get_tile_domain", |
|||
[], |
|||
{ |
|||
base_domain: [['state', 'in', ['draft','sent']]], |
|||
filters: this.state.filters |
|||
} |
|||
); |
|||
|
|||
this.actionService.doAction({ |
|||
type: "ir.actions.act_window", |
|||
name: "Quotations", |
|||
res_model: "sale.order", |
|||
domain, |
|||
views: [[false, "list"], [false, "form"]] |
|||
}) |
|||
} |
|||
|
|||
async viewToInvoiceOrders(){ |
|||
const domain = await this.orm.call( |
|||
"sale.order", |
|||
"get_tile_domain", |
|||
[], |
|||
{ |
|||
base_domain: [['invoice_status', '=', 'to invoice']], |
|||
filters: this.state.filters |
|||
} |
|||
); |
|||
|
|||
this.actionService.doAction({ |
|||
type: "ir.actions.act_window", |
|||
name: "To Invoice", |
|||
res_model: "sale.order", |
|||
domain, |
|||
views: [[false, "list"], [false, "form"]] |
|||
}) |
|||
} |
|||
|
|||
async viewFullyInvoicedOrders(){ |
|||
const domain = await this.orm.call( |
|||
"sale.order", |
|||
"get_tile_domain", |
|||
[], |
|||
{ |
|||
base_domain: [['invoice_status', '=', 'invoiced']], |
|||
filters: this.state.filters |
|||
} |
|||
); |
|||
|
|||
this.actionService.doAction({ |
|||
type: "ir.actions.act_window", |
|||
name: "Fully Invoiced", |
|||
res_model: "sale.order", |
|||
domain, |
|||
views: [[false, "list"], [false, "form"]] |
|||
}) |
|||
} |
|||
|
|||
_render_charts() { |
|||
const d = this.state.data; |
|||
if (!d.sales_by_team) return; |
|||
Object.values(this.charts).forEach(chart => chart?.destroy()); |
|||
this.charts = {}; |
|||
|
|||
const pie = (id, labels, data, bg, chartKey) => { |
|||
const ctx = document.getElementById(id); |
|||
if (!ctx) return; |
|||
this.charts[chartKey] = new Chart(ctx, { |
|||
type: "pie", |
|||
data: { |
|||
labels, |
|||
datasets: [{ data, backgroundColor: bg }], |
|||
}, |
|||
options: { |
|||
responsive: true, |
|||
plugins: { legend: { position: 'bottom' } } |
|||
} |
|||
}); |
|||
}; |
|||
const bar = (id, labels, data, bg, chartKey) => { |
|||
const ctx = document.getElementById(id); |
|||
if (!ctx) return; |
|||
this.charts[chartKey] = new Chart(ctx, { |
|||
type: "bar", |
|||
data: { |
|||
labels, |
|||
datasets: [{ label: "", data, backgroundColor: bg }], |
|||
}, |
|||
options: { |
|||
responsive: true, |
|||
scales: { y: { beginAtZero: true } }, |
|||
plugins: { legend: { display: false } } |
|||
} |
|||
}); |
|||
}; |
|||
|
|||
pie("salesByTeamChart", |
|||
d.sales_by_team.map(x => x.name), |
|||
d.sales_by_team.map(x => x.amount), |
|||
["#007bff", "#28a745", "#ffc107", "#dc3545", "#ff7e00"], |
|||
"team"); |
|||
|
|||
pie("salesByPersonChart", |
|||
d.sales_by_person.map(x => x.name), |
|||
d.sales_by_person.map(x => x.amount), |
|||
["#17a2b8", "#ffc107", "#6c757d", "#6610f2"], |
|||
"person"); |
|||
|
|||
bar("topProductsChart", |
|||
d.top_products.map(x => x.name), |
|||
d.top_products.map(x => x.qty), |
|||
"#28a745", |
|||
"top_products"); |
|||
|
|||
bar("lowestProductsChart", |
|||
d.lowest_products.map(x => x.name), |
|||
d.lowest_products.map(x => x.qty), |
|||
"#dc3545", |
|||
"lowest_products"); |
|||
|
|||
pie("orderStatusChart", |
|||
d.order_status.map(x => x.status), |
|||
d.order_status.map(x => x.count), |
|||
["#6c757d", "#17a2b8", "#28a745", "#ffc107"], |
|||
"order_status"); |
|||
|
|||
pie("invoiceStatusChart", |
|||
d.invoice_status.map(x => x.status), |
|||
d.invoice_status.map(x => x.count), |
|||
["#343a40", "#28a745", "#ffc107"], |
|||
"invoice_status"); |
|||
|
|||
bar( |
|||
"overdueCustomersChart", |
|||
d.overdue_customers.map(x => x.name), |
|||
d.overdue_customers.map(x => x.amount), |
|||
d.overdue_customers.map(x => |
|||
x.amount > 10000 ? "red" : |
|||
x.amount > 5000 ? "orange" : |
|||
"yellow"), |
|||
"overdue_customers"); |
|||
|
|||
pie("newVsReturningChart", |
|||
d.new_vs_returning.summary.labels, |
|||
d.new_vs_returning.summary.values, |
|||
["#28a745", "#ffc107"], |
|||
"new_vs_returning"); |
|||
} |
|||
|
|||
onChangeGlobalFilter(ev) { |
|||
this.state.filters.global_filter = ev.target.value; |
|||
|
|||
if (this.state.filters.global_filter === "select_period") { |
|||
this.state.filters = { |
|||
global_filter: "select_period", |
|||
custom_range: { from: null, to: null }, |
|||
limit: "10", |
|||
team_filter: "this_week", |
|||
person_filter: "this_week", |
|||
product_filter: "this_week", |
|||
low_product_filter: "this_week", |
|||
customer_filter: "this_week", |
|||
order_filter: "this_week", |
|||
invoice_filter: "this_week", |
|||
overdue_filter: "this_week", |
|||
nvrc_filter: "this_week", |
|||
}; |
|||
|
|||
this.render(); |
|||
this._fetch_data(); |
|||
return; |
|||
} |
|||
|
|||
if (this.state.filters.global_filter !== "custom") { |
|||
this.state.filters.custom_range = { from: null, to: null }; |
|||
} |
|||
|
|||
this._fetch_data(); |
|||
} |
|||
|
|||
onChangeCustomFrom(ev) { |
|||
this.state.filters.custom_range.from = ev.target.value; |
|||
this._maybeFetchCustom(); |
|||
} |
|||
|
|||
onChangeCustomTo(ev) { |
|||
this.state.filters.custom_range.to = ev.target.value; |
|||
this._maybeFetchCustom(); |
|||
} |
|||
|
|||
_maybeFetchCustom() { |
|||
if (this.state.filters.global_filter === "custom" && |
|||
this.state.filters.custom_range.from && |
|||
this.state.filters.custom_range.to) { |
|||
this._fetch_data(); |
|||
} |
|||
} |
|||
|
|||
onChangeTeamFilter(ev) { |
|||
if (this.state.filters.global_filter === "custom") return; |
|||
this.state.filters.team_filter = ev.target.value; |
|||
this._fetch_data(); |
|||
} |
|||
onChangePersonFilter(ev) { |
|||
if (this.state.filters.global_filter === "custom") return; |
|||
this.state.filters.person_filter = ev.target.value; |
|||
this._fetch_data(); |
|||
} |
|||
onChangeCustomerFilter(ev) { |
|||
if (this.state.filters.global_filter === "custom") return; |
|||
this.state.filters.customer_filter = ev.target.value; |
|||
this._fetch_data(); |
|||
} |
|||
onChangeOrderFilter(ev) { |
|||
if (this.state.filters.global_filter === "custom") return; |
|||
this.state.filters.order_filter = ev.target.value; |
|||
this._fetch_data(); |
|||
} |
|||
|
|||
onChangeInvoiceFilter(ev) { |
|||
if (this.state.filters.global_filter === "custom") return; |
|||
this.state.filters.invoice_filter = ev.target.value; |
|||
this._fetch_data(); |
|||
} |
|||
onChangeProductFilter(ev) { |
|||
if (this.state.filters.global_filter === "custom") return; |
|||
this.state.filters.product_filter = ev.target.value; |
|||
this._fetch_data(); |
|||
} |
|||
onChangeProductCategory(ev) { |
|||
const val = ev.target.value; |
|||
this.state.filters.product_category_id = val ? parseInt(val) : null; |
|||
this._fetch_data(); |
|||
} |
|||
onChangeLowProductFilter(ev) { |
|||
if (this.state.filters.global_filter === "custom") return; |
|||
this.state.filters.low_product_filter = ev.target.value; |
|||
this._fetch_data(); |
|||
} |
|||
onChangeLowProductCategory(ev) { |
|||
const val = ev.target.value; |
|||
this.state.filters.low_product_category_id = val ? parseInt(val) : null; |
|||
this._fetch_data(); |
|||
} |
|||
onChangeOverdueFilter(ev) { |
|||
if (this.state.filters.global_filter === "custom") return; |
|||
this.state.filters.overdue_filter = ev.target.value; |
|||
this._fetch_data(); |
|||
} |
|||
onChangeNvRFilter(ev) { |
|||
if (this.state.filters.global_filter === "custom") return; |
|||
this.state.filters.nvrc_filter = ev.target.value; |
|||
this._fetch_data(); |
|||
} |
|||
onChangeGlobalLimit(ev) { |
|||
this.state.filters.limit = parseInt(ev.target.value); |
|||
this._fetch_data(); |
|||
} |
|||
} |
|||
SalesDashboard.template = "sales_dashboard.SalesDashboardTemplate"; |
|||
registry.category("actions").add("sales_dashboard", SalesDashboard); |
|||
@ -1,563 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8" ?> |
|||
<templates id="template" xml:space="preserve"> |
|||
<t t-name="sales_dashboard.SalesDashboardTemplate" owl="1"> |
|||
<div class="o_sales_dashboard p-4" style="max-height: 90vh; overflow-y: auto;"> |
|||
<h2 class="mb-4 fw-bold text-center text-primary" style="backdrop-filter: blur(10px); |
|||
border-radius: 12px; |
|||
padding: 12px 20px; |
|||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);">📊 Sales Dashboard</h2> |
|||
<div class="flex items-center gap-2 mb-4" style="width: 100px ; min-width: 150px; border-radius: 0.5rem;"> |
|||
<label class="font-semibold">Global Filter:</label> |
|||
<select id="global_filter" t-on-change="onChangeGlobalFilter" |
|||
t-model="state.filters.global_filter" |
|||
class="border rounded p-1" style="box-shadow: 0 2px 6px rgba(0,0,0,0.2);"> |
|||
<option value="select_period">Select Period</option> |
|||
<option value="this_week">This Week</option> |
|||
<option value="this_month">This Month</option> |
|||
<option value="this_year">This Year</option> |
|||
<option value="custom">Custom Range</option> |
|||
</select> |
|||
|
|||
<input type="date" |
|||
t-if="state.filters.global_filter === 'custom'" |
|||
t-on-change="onChangeCustomFrom" |
|||
class="border rounded p-1" /> |
|||
|
|||
<input type="date" |
|||
t-if="state.filters.global_filter === 'custom'" |
|||
t-on-change="onChangeCustomTo" |
|||
class="border rounded p-1" /> |
|||
|
|||
<div class="flex items-center gap-2" style="min-width: 100px;"> |
|||
<label class="font-semibold">Limit:</label> |
|||
<select id="global_limit" |
|||
t-on-change="onChangeGlobalLimit" |
|||
t-model="state.filters.limit" |
|||
class="border rounded p-1" |
|||
style="box-shadow: 0 2px 6px rgba(0,0,0,0.2);"> |
|||
<option value="5">5</option> |
|||
<option value="10">10</option> |
|||
<option value="15">15</option> |
|||
<option value="20">20</option> |
|||
<option value="25">25</option> |
|||
<option value="30">30</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="row justify-content-center my-4"> |
|||
<!-- First Row --> |
|||
<!-- Sale Orders --> |
|||
<div class="col-12 col-sm-6 col-lg-3"> |
|||
<div class="card border rounded-4 text-center shadow-sm position-relative" style="cursor: pointer; height: 160px; background-color: #3490dc;" role="button" t-on-click="viewOrders"> |
|||
<div class="icon-box position-absolute" |
|||
style="top: 15px; left: 15px; |
|||
background: linear-gradient(135deg, rgba(255,255,255,0.25), rgba(255,255,255,0.05)); |
|||
width: 48px; height: 48px; |
|||
display: flex; align-items: center; justify-content: center; |
|||
border-radius: 12px; |
|||
backdrop-filter: blur(8px); |
|||
border: 1px solid rgba(255,255,255,0.2); |
|||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);"> |
|||
<i class="fa fa-shopping-cart text-white" style="font-size: 22px;"></i> |
|||
</div> |
|||
<div class="card-body d-flex flex-column justify-content-center mt-2"> |
|||
<h3 class="mb-2 fw-bold">Sale Orders</h3> |
|||
<div class="fw-bold" style="font-size: 20px; color: #f8f9fa;"> |
|||
<t t-esc="state.data.sales_info.sale_orders"/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Quotations --> |
|||
<div class="col-12 col-sm-6 col-lg-3"> |
|||
<div class="card border rounded-4 text-center shadow-sm position-relative" style="cursor: pointer; height: 160px; background-color: #38c172;" role="button" t-on-click="viewQuotations"> |
|||
<div class="icon-box position-absolute" |
|||
style="top: 15px; left: 15px; |
|||
background: linear-gradient(135deg, rgba(255,255,255,0.25), rgba(255,255,255,0.05)); |
|||
width: 48px; height: 48px; |
|||
display: flex; align-items: center; justify-content: center; |
|||
border-radius: 12px; |
|||
backdrop-filter: blur(8px); |
|||
border: 1px solid rgba(255,255,255,0.2); |
|||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);"> |
|||
<i class="fa fa-file-text-o text-white" style="font-size: 22px;"></i> |
|||
</div> |
|||
<div class="card-body d-flex flex-column justify-content-center mt-2"> |
|||
<h3 class="mb-2 fw-bold">Quotations</h3> |
|||
<div class="fw-bold" style="font-size: 20px; color: #f8f9fa;"> |
|||
<t t-esc="state.data.sales_info.quotation"/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Orders to Invoice --> |
|||
<div class="col-12 col-sm-6 col-lg-3"> |
|||
<div class="card border rounded-4 text-center shadow-sm position-relative" style="cursor: pointer; height: 160px; background-color: #ff9800;" role="button" t-on-click="viewToInvoiceOrders"> |
|||
<div class="icon-box position-absolute" |
|||
style="top: 15px; left: 15px; |
|||
background: linear-gradient(135deg, rgba(255,255,255,0.25), rgba(255,255,255,0.05)); |
|||
width: 48px; height: 48px; |
|||
display: flex; align-items: center; justify-content: center; |
|||
border-radius: 12px; |
|||
backdrop-filter: blur(8px); |
|||
border: 1px solid rgba(255,255,255,0.2); |
|||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);"> |
|||
<i class="fa fa-credit-card text-white" style="font-size: 22px;"></i> |
|||
</div> |
|||
<div class="card-body d-flex flex-column justify-content-center mt-2"> |
|||
<h3 class="mb-2 fw-bold">Orders to Invoice</h3> |
|||
<div class="fw-bold" style="font-size: 20px; color: #f8f9fa;"> |
|||
<t t-esc="state.data.sales_info.orders_to_invoice"/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Fully Invoiced --> |
|||
<div class="col-12 col-sm-6 col-lg-3"> |
|||
<div class="card border rounded-4 text-center shadow-sm position-relative" style="cursor: pointer; height: 160px; background-color: #9f7aea;" role="button" t-on-click="viewFullyInvoicedOrders"> |
|||
<div class="icon-box position-absolute" |
|||
style="top: 15px; left: 15px; |
|||
background: linear-gradient(135deg, rgba(255,255,255,0.25), rgba(255,255,255,0.05)); |
|||
width: 48px; height: 48px; |
|||
display: flex; align-items: center; justify-content: center; |
|||
border-radius: 12px; |
|||
backdrop-filter: blur(8px); |
|||
border: 1px solid rgba(255,255,255,0.2); |
|||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);"> |
|||
<i class="fa fa-check-circle text-white" style="font-size: 22px;"></i> |
|||
</div> |
|||
<div class="card-body d-flex flex-column justify-content-center mt-2"> |
|||
<h3 class="mb-2 fw-bold">Fully Invoiced</h3> |
|||
<div class="fw-bold" style="font-size: 20px; color: #f8f9fa;"> |
|||
<t t-esc="state.data.sales_info.orders_fully_invoiced"/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- Second Row --> |
|||
<!-- Order Conversion Rate --> |
|||
<div class="row justify-content-center my-4"> |
|||
<div class="col-12 col-sm-6 col-lg-3"> |
|||
<div class="card border rounded-4 text-center shadow-sm position-relative" style="cursor: pointer; height: 160px; background-color: #ff9800;"> |
|||
<div class="icon-box position-absolute" |
|||
style="top: 15px; left: 15px; |
|||
background: linear-gradient(135deg, rgba(255,255,255,0.25), rgba(255,255,255,0.05)); |
|||
width: 48px; height: 48px; |
|||
display: flex; align-items: center; justify-content: center; |
|||
border-radius: 12px; |
|||
backdrop-filter: blur(8px); |
|||
border: 1px solid rgba(255,255,255,0.2); |
|||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);"> |
|||
<i class="fa fa-angle-double-right text-white" style="font-size: 22px;"></i> |
|||
</div> |
|||
<div class="card-body d-flex flex-column justify-content-center mt-2"> |
|||
<h3 class="mb-2 fw-bold">Order Conversion Rate</h3> |
|||
<div class="fw-bold" style="font-size: 20px; color: #f8f9fa;"> |
|||
<t t-esc="state.data.sales_info.conversion_rate"/>% |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- Total Revenue MTD --> |
|||
<div class="col-12 col-sm-6 col-lg-3"> |
|||
<div class="card border rounded-4 text-center shadow-sm position-relative" style="cursor: pointer; height: 160px; background-color: #9f7aea;"> |
|||
<div class="icon-box position-absolute" |
|||
style="top: 15px; left: 15px; |
|||
background: linear-gradient(135deg, rgba(255,255,255,0.25), rgba(255,255,255,0.05)); |
|||
width: 48px; height: 48px; |
|||
display: flex; align-items: center; justify-content: center; |
|||
border-radius: 12px; |
|||
backdrop-filter: blur(8px); |
|||
border: 1px solid rgba(255,255,255,0.2); |
|||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);"> |
|||
<i class="fa fa-money text-white" style="font-size: 22px;"></i> |
|||
</div> |
|||
<div class="card-body d-flex flex-column justify-content-center mt-2"> |
|||
<h3 class="mb-2 fw-bold">Total Revenue MTD</h3> |
|||
<div class="fw-bold" style="font-size: 20px; color: #f8f9fa;"> |
|||
<t t-esc="state.data.sales_info.total_revenue_mtd"/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- Total Revenue YTD --> |
|||
<div class="col-12 col-sm-6 col-lg-3"> |
|||
<div class="card border rounded-4 text-center shadow-sm position-relative" style="cursor: pointer; height: 160px; background-color: #3490dc;"> |
|||
<div class="icon-box position-absolute" |
|||
style="top: 15px; left: 15px; |
|||
background: linear-gradient(135deg, rgba(255,255,255,0.25), rgba(255,255,255,0.05)); |
|||
width: 48px; height: 48px; |
|||
display: flex; align-items: center; justify-content: center; |
|||
border-radius: 12px; |
|||
backdrop-filter: blur(8px); |
|||
border: 1px solid rgba(255,255,255,0.2); |
|||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);"> |
|||
<i class="fa fa-money text-white" style="font-size: 22px;"></i> |
|||
</div> |
|||
<div class="card-body d-flex flex-column justify-content-center mt-2"> |
|||
<h3 class="mb-2 fw-bold">Total Revenue YTD</h3> |
|||
<div class="fw-bold" style="font-size: 20px; color: #f8f9fa;"> |
|||
<t t-esc="state.data.sales_info.total_revenue_ytd"/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- Average Order Value --> |
|||
<div class="col-12 col-sm-6 col-lg-3"> |
|||
<div class="card border rounded-4 text-center shadow-sm position-relative" style="cursor: pointer; height: 160px; background-color: #38c172;"> |
|||
<div class="icon-box position-absolute" |
|||
style="top: 15px; left: 15px; |
|||
background: linear-gradient(135deg, rgba(255,255,255,0.25), rgba(255,255,255,0.05)); |
|||
width: 48px; height: 48px; |
|||
display: flex; align-items: center; justify-content: center; |
|||
border-radius: 12px; |
|||
backdrop-filter: blur(8px); |
|||
border: 1px solid rgba(255,255,255,0.2); |
|||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);"> |
|||
<i class="fa fa-money text-white" style="font-size: 22px;"></i> |
|||
</div> |
|||
<div class="card-body d-flex flex-column justify-content-center mt-2"> |
|||
<h3 class="mb-2 fw-bold">Average Order Value</h3> |
|||
<div class="fw-bold" style="font-size: 20px; color: #f8f9fa;"> |
|||
<t t-esc="state.data.sales_info.avg_order_value"/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="row mb-4"> |
|||
<!-- Sales by Team --> |
|||
<div class="col-md-6 mb-3"> |
|||
<div class="card shadow-sm"> |
|||
<div class="card-header bg-primary text-white fw-bold">Sales by Team</div> |
|||
<div class="d-flex justify-content-between align-items-center mb-2"> |
|||
<t t-if="state.filters.global_filter == 'select_period'"> |
|||
<select class="form-select w-auto" style="box-shadow: 0 2px 6px rgba(0,0,0,0.2);" t-on-change="onChangeTeamFilter" t-att-value="state.filters.team_filter"> |
|||
<option value="this_week">This Week</option> |
|||
<option value="this_month">This Month</option> |
|||
<option value="this_year">This Year</option> |
|||
</select> |
|||
</t> |
|||
</div> |
|||
<div class="card-body"> |
|||
<canvas id="salesByTeamChart" style="max-height: 50vh;display: block; box-sizing: border-box; height: 789px; width: 525px; cursor: pointer;"></canvas> |
|||
<table class="table table-bordered table-sm table-hover mt-3"> |
|||
<thead class="table-light"> |
|||
<tr> |
|||
<th>Team</th> |
|||
<th class="text-end">Amount</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<t t-foreach="state.data.sales_by_team || []" t-as="team" t-key="team.id"> |
|||
<tr class="clickable-row" t-on-click="() => goToRecord('crm.team', team.id)"> |
|||
<td t-esc="team.name"/> |
|||
<td class="text-end fw-bold" t-esc="team.amount"/> |
|||
</tr> |
|||
</t> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- Sales by Person --> |
|||
<div class="col-md-6 mb-3"> |
|||
<div class="card shadow-sm"> |
|||
<div class="card-header bg-info text-white fw-bold">Sales by Person</div> |
|||
<div class="d-flex justify-content-between align-items-center mb-2"> |
|||
<t t-if="state.filters.global_filter == 'select_period'"> |
|||
<select class="form-select w-auto" style="box-shadow: 0 2px 6px rgba(0,0,0,0.2);" t-on-change="onChangePersonFilter" t-att-value="state.filters.person_filter"> |
|||
<option value="this_week">This Week</option> |
|||
<option value="this_month">This Month</option> |
|||
<option value="this_year">This Year</option> |
|||
</select> |
|||
</t> |
|||
</div> |
|||
<div class="card-body"> |
|||
<canvas id="salesByPersonChart" style="max-height: 50vh;display: block; box-sizing: border-box; height: 789px; width: 525px; cursor: pointer;"></canvas> |
|||
<table class="table table-bordered table-sm table-hover mt-3"> |
|||
<thead class="table-light"> |
|||
<tr><th>Salesperson</th><th class="text-end">Amount</th></tr> |
|||
</thead> |
|||
<tbody> |
|||
<t t-foreach="state.data.sales_by_person || []" t-as="person" t-key="person.id"> |
|||
<tr class="clickable-row" t-on-click="() => goToRecord('res.users', person.id)"> |
|||
<td t-esc="person.name"/> |
|||
<td class="text-end fw-bold" t-esc="person.amount"/> |
|||
</tr> |
|||
</t> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- Row 2: Top & Lowest Products --> |
|||
<div class="row mb-4"> |
|||
<!-- Top Products --> |
|||
<div class="col-md-6 mb-3"> |
|||
<div class="card shadow-sm"> |
|||
<div class="card-header bg-success text-white fw-bold">Top Selling Products</div> |
|||
<div class="d-flex justify-content-between align-items-center mb-2"> |
|||
<div class="d-flex gap-2"> |
|||
<t t-if="state.filters.global_filter == 'select_period'"> |
|||
<select class="form-select form-select-sm w-auto" style="box-shadow: 0 2px 6px rgba(0,0,0,0.2);" t-on-change="onChangeProductFilter" t-att-value="state.filters.product_filter"> |
|||
<option value="this_week">This Week</option> |
|||
<option value="this_month">This Month</option> |
|||
<option value="this_year">This Year</option> |
|||
</select> |
|||
</t> |
|||
<select class="form-select form-select-sm w-auto" style="box-shadow: 0 2px 6px rgba(0,0,0,0.2);" t-on-change="onChangeProductCategory"> |
|||
<option value="">All Categories</option> |
|||
<t t-foreach="state.data.product_categories || []" t-as="cat" t-key="cat.id"> |
|||
<option t-att-value="cat.id" t-esc="cat.name"/> |
|||
</t> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
<div class="card-body"> |
|||
<canvas id="topProductsChart" height="200" style="cursor: pointer;"></canvas> |
|||
<table class="table table-bordered table-sm table-hover mt-3"> |
|||
<thead class="table-light"> |
|||
<tr> |
|||
<th>Product</th> |
|||
<th class="text-end">Quantity Sold</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<t t-foreach="state.data.top_products || []" t-as="prod" t-key="prod.id"> |
|||
<tr class="clickable-row" t-on-click="() => goToRecord('product.product', prod.id)"> |
|||
<td t-esc="prod.name"/> |
|||
<td class="text-end fw-bold" t-esc="prod.qty"/> |
|||
</tr> |
|||
</t> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- Lowest Products --> |
|||
<div class="col-md-6 mb-3"> |
|||
<div class="card shadow-sm"> |
|||
<div class="card-header bg-danger text-white fw-bold">Least Selling Products</div> |
|||
<div class="d-flex justify-content-between align-items-center mb-2"> |
|||
<div class="d-flex gap-2"> |
|||
<t t-if="state.filters.global_filter == 'select_period'"> |
|||
<select class="form-select form-select-sm w-auto" style="box-shadow: 0 2px 6px rgba(0,0,0,0.2);" t-on-change="onChangeLowProductFilter" t-att-value="state.filters.low_product_filter"> |
|||
<option value="this_week">This Week</option> |
|||
<option value="this_month">This Month</option> |
|||
<option value="this_year">This Year</option> |
|||
</select> |
|||
</t> |
|||
<select class="form-select form-select-sm w-auto" style="box-shadow: 0 2px 6px rgba(0,0,0,0.2);" t-on-change="onChangeLowProductCategory"> |
|||
<option value="">All Categories</option> |
|||
<t t-foreach="state.data.product_categories || []" t-as="cat" t-key="cat.id"> |
|||
<option t-att-value="cat.id" t-esc="cat.name"/> |
|||
</t> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
<div class="card-body"> |
|||
<canvas id="lowestProductsChart" height="200" style="cursor: pointer;"></canvas> |
|||
<table class="table table-bordered table-sm table-hover mt-3"> |
|||
<thead class="table-light"> |
|||
<tr><th>Product</th><th class="text-end">Quantity Sold</th></tr> |
|||
</thead> |
|||
<tbody> |
|||
<t t-foreach="state.data.lowest_products || []" t-as="prod" t-key="prod.id"> |
|||
<tr class="clickable-row" t-on-click="() => goToRecord('product.product', prod.id)"> |
|||
<td t-esc="prod.name"/> |
|||
<td class="text-end fw-bold" t-esc="prod.qty"/> |
|||
</tr> |
|||
</t> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- Row 3: Top Customers --> |
|||
<div class="row mb-4"> |
|||
<div class="col-12"> |
|||
<div class="card shadow-sm"> |
|||
<div class="card-header bg-warning text-white fw-bold">Top Customers</div> |
|||
<div class="d-flex justify-content-between align-items-center mb-2"> |
|||
<t t-if="state.filters.global_filter == 'select_period'"> |
|||
<select class="form-select w-auto" style="box-shadow: 0 2px 6px rgba(0,0,0,0.2);" t-on-change="onChangeCustomerFilter" t-att-value="state.filters.customer_filter"> |
|||
<option value="this_week">This Week</option> |
|||
<option value="this_month">This Month</option> |
|||
<option value="this_year">This Year</option> |
|||
</select> |
|||
</t> |
|||
</div> |
|||
<div class="card-body"> |
|||
<table class="table table-bordered table-sm table-hover"> |
|||
<thead class="table-light"> |
|||
<tr><th>Customer</th><th class="text-end">Amount</th></tr> |
|||
</thead> |
|||
<tbody> |
|||
<t t-foreach="state.data.top_customers || []" t-as="cust" t-key="cust.id"> |
|||
<tr class="clickable-row" t-on-click="() => goToRecord('res.partner', cust.id)"> |
|||
<td t-esc="cust.name"/> |
|||
<td class="text-end fw-bold" t-esc="cust.amount"/> |
|||
</tr> |
|||
</t> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- Row 4: Order & Invoice Status --> |
|||
<div class="row mb-4"> |
|||
<!-- Order Status --> |
|||
<div class="col-md-6 mb-3"> |
|||
<div class="card shadow-sm"> |
|||
<div class="card-header bg-secondary text-black fw-bold">Order Status</div> |
|||
<div class="d-flex justify-content-between align-items-center mb-2"> |
|||
<t t-if="state.filters.global_filter == 'select_period'"> |
|||
<select class="form-select w-auto" style="box-shadow: 0 2px 6px rgba(0,0,0,0.2);" t-on-change="onChangeOrderFilter" t-att-value="state.filters.order_filter"> |
|||
<option value="this_week">This Week</option> |
|||
<option value="this_month">This Month</option> |
|||
<option value="this_year">This Year</option> |
|||
</select> |
|||
</t> |
|||
</div> |
|||
<div class="card-body"> |
|||
<canvas id="orderStatusChart" style="max-height: 50vh;display: block; box-sizing: border-box; height: 789px; width: 525px; cursor: pointer;"></canvas> |
|||
<table class="table table-bordered table-sm table-hover mt-3"> |
|||
<thead class="table-light"> |
|||
<tr><th>Status</th><th class="text-end">Count</th></tr> |
|||
</thead> |
|||
<tbody> |
|||
<t t-foreach="state.data.order_status || []" t-as="order" t-key="order.status"> |
|||
<tr> |
|||
<td t-esc="order.status"/> |
|||
<td class="text-end fw-bold" t-esc="order.count"/> |
|||
</tr> |
|||
</t> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- Invoice Status --> |
|||
<div class="col-md-6 mb-3"> |
|||
<div class="card shadow-sm"> |
|||
<div class="card-header bg-dark text-white fw-bold">Invoice Status</div> |
|||
<div class="d-flex justify-content-between align-items-center mb-2"> |
|||
<t t-if="state.filters.global_filter == 'select_period'"> |
|||
<select class="form-select w-auto" style="box-shadow: 0 2px 6px rgba(0,0,0,0.2);" t-on-change="onChangeInvoiceFilter" t-att-value="state.filters.invoice_filter"> |
|||
<option value="this_week">This Week</option> |
|||
<option value="this_month">This Month</option> |
|||
<option value="this_year">This Year</option> |
|||
</select> |
|||
</t> |
|||
</div> |
|||
<div class="card-body"> |
|||
<canvas id="invoiceStatusChart" style="max-height: 50vh;display: block; box-sizing: border-box; height: 789px; width: 525px; cursor: pointer;"></canvas> |
|||
<table class="table table-bordered table-sm table-hover mt-3"> |
|||
<thead class="table-light"> |
|||
<tr><th>Status</th><th class="text-end">Count</th></tr> |
|||
</thead> |
|||
<tbody> |
|||
<t t-foreach="state.data.invoice_status || []" t-as="inv" t-key="inv.status"> |
|||
<tr> |
|||
<td t-esc="inv.status"/> |
|||
<td class="text-end fw-bold" t-esc="inv.count"/> |
|||
</tr> |
|||
</t> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- Row 5: Overdue Customers & New vs Returning Customers --> |
|||
<div class="row mb-4"> |
|||
<!-- Overdue Customers --> |
|||
<div class="col-md-6 mb-3"> |
|||
<div class="card shadow-sm"> |
|||
<div class="card-header bg-danger text-white fw-bold">Overdue Customers</div> |
|||
<div class="d-flex justify-content-between align-items-center mb-2"> |
|||
<t t-if="state.filters.global_filter == 'select_period'"> |
|||
<select class="form-select w-auto" style="box-shadow: 0 2px 6px rgba(0,0,0,0.2);" t-on-change="onChangeOverdueFilter" t-att-value="state.filters.team_filter"> |
|||
<option value="this_week">This Week</option> |
|||
<option value="this_month">This Month</option> |
|||
<option value="this_year">This Year</option> |
|||
</select> |
|||
</t> |
|||
</div> |
|||
<div class="card-body"> |
|||
<canvas id="overdueCustomersChart" style="max-height: 50vh;display: block; box-sizing: border-box; height: 789px; width: 525px; cursor: pointer;"></canvas> |
|||
<table class="table table-bordered table-sm table-hover mt-3"> |
|||
<thead class="table-light"> |
|||
<tr><th>Customer</th><th class="text-end">Amount</th></tr> |
|||
</thead> |
|||
<tbody> |
|||
<t t-foreach="state.data.overdue_customers || []" t-as="cust" t-key="cust.id"> |
|||
<tr class="clickable-row" t-on-click="() => goToRecord('res.partner', cust.id)"> |
|||
<td t-esc="cust.name"/> |
|||
<td class="text-end fw-bold" t-esc="cust.amount"/> |
|||
</tr> |
|||
</t> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- New vs Returning Customers --> |
|||
<div class="col-md-6 mb-3"> |
|||
<div class="card shadow-sm"> |
|||
<div class="card-header bg-success text-white fw-bold">New vs Returning Customers</div> |
|||
<div class="d-flex justify-content-between align-items-center mb-2"> |
|||
<t t-if="state.filters.global_filter == 'select_period'"> |
|||
<select class="form-select w-auto" style="box-shadow: 0 2px 6px rgba(0,0,0,0.2);" t-on-change="onChangeNvRFilter" t-att-value="state.filters.team_filter"> |
|||
<option value="this_week">This Week</option> |
|||
<option value="this_month">This Month</option> |
|||
<option value="this_year">This Year</option> |
|||
</select> |
|||
</t> |
|||
</div> |
|||
<div class="card-body"> |
|||
<canvas id="newVsReturningChart" style="max-height: 50vh;display: block; box-sizing: border-box; height: 789px; width: 525px; cursor: pointer;"></canvas> |
|||
<t t-set="customerData" t-value="state.data.new_vs_returning"/> |
|||
<div t-if="customerData and customerData.details"> |
|||
<h5 class="mt-3">New Customers</h5> |
|||
<table class="table table-bordered table-sm table-hover mt-2"> |
|||
<thead class="table-light"> |
|||
<tr><th>Customer</th></tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr t-foreach="customerData.details.new" t-as="cust" t-key="cust_index"> |
|||
<td class="clickable-row" t-on-click="() => this.goToRecord('res.partner', cust.id)" t-esc="cust.name"/> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
|
|||
<!-- Returning Customers --> |
|||
<h5 class="mt-3">Returning Customers</h5> |
|||
<table class="table table-bordered table-sm table-hover mt-2"> |
|||
<thead class="table-light"> |
|||
<tr><th>Customer</th></tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr t-foreach="customerData.details.returning" t-as="cust" t-key="cust_index"> |
|||
<td class="clickable-row" t-on-click="() => this.goToRecord('res.partner', cust.id)" t-esc="cust.name"/> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</t> |
|||
</templates> |
|||
@ -1,9 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8" ?> |
|||
<odoo> |
|||
<record id="action_sales_dashboard" model="ir.actions.client"> |
|||
<field name="name">Dashboard</field> |
|||
<field name="tag">sales_dashboard</field> |
|||
</record> |
|||
|
|||
<menuitem id="menu_sales_dashboard_root" name="Sales Dashboard" parent="sale.sale_menu_root" sequence="5" action="action_sales_dashboard"/> |
|||
</odoo> |
|||