@ -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> |
|
||||