diff --git a/.gitignore b/.gitignore index b6e47617de1..d452c1fca09 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,13 @@ dmypy.json # Pyre type checker .pyre/ + +# JetBrains specific folders +.idea/ +*/.idea/ + +# Odoo specific folders +.run +*/.run/ +.odev +*/.odev/ diff --git a/awesome_dashboard/__init__.py b/awesome_dashboard/__init__.py index b0f26a9a602..91c5580fed3 100644 --- a/awesome_dashboard/__init__.py +++ b/awesome_dashboard/__init__.py @@ -1,3 +1,2 @@ -# -*- coding: utf-8 -*- - from . import controllers +from . import models diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index a1cd72893d7..016be91767d 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- { 'name': "Awesome Dashboard", @@ -20,6 +19,7 @@ 'data': [ 'views/views.xml', + 'views/res_users_views.xml', ], 'assets': { 'web.assets_backend': [ diff --git a/awesome_dashboard/controllers/controllers.py b/awesome_dashboard/controllers/controllers.py index 56d4a051287..05977d3bd7f 100644 --- a/awesome_dashboard/controllers/controllers.py +++ b/awesome_dashboard/controllers/controllers.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) class AwesomeDashboard(http.Controller): - @http.route('/awesome_dashboard/statistics', type='json', auth='user') + @http.route('/awesome_dashboard/statistics', type='jsonrpc', auth='user') def get_statistics(self): """ Returns a dict of statistics about the orders: diff --git a/awesome_dashboard/i18n/en_US.po b/awesome_dashboard/i18n/en_US.po new file mode 100644 index 00000000000..35c903c3969 --- /dev/null +++ b/awesome_dashboard/i18n/en_US.po @@ -0,0 +1,87 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * awesome_dashboard +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-10-31 14:36+0000\n" +"PO-Revision-Date: 2025-10-31 14:36+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_item.js:0 +msgid "Average amount of t-shirt by order this month" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_item.js:0 +msgid "Average time for an order to go from 'new' to 'sent' or 'cancelled'" +msgstr "" + +#. module: awesome_dashboard +#: model:ir.ui.menu,name:awesome_dashboard.menu_root +msgid "Awesome Dashboard" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard.xml:0 +msgid "Customers" +msgstr "" + +#. module: awesome_dashboard +#: model:ir.actions.client,name:awesome_dashboard.dashboard +#: model:ir.ui.menu,name:awesome_dashboard.dashboard_menu +msgid "Dashboard" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_configuration/dashboard_configuration.xml:0 +msgid "Done" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard.xml:0 +msgid "Leads" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_item.js:0 +msgid "Number of cancelled orders this month" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_item.js:0 +msgid "Number of new orders this month" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_item.js:0 +msgid "Shirt orders by size" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_item.js:0 +msgid "Total amount of new orders this month" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_configuration/dashboard_configuration.xml:0 +msgid "Which cards do you wish to see ?" +msgstr "" diff --git a/awesome_dashboard/i18n/fr_BE.po b/awesome_dashboard/i18n/fr_BE.po new file mode 100644 index 00000000000..2c29ccf3606 --- /dev/null +++ b/awesome_dashboard/i18n/fr_BE.po @@ -0,0 +1,87 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * awesome_dashboard +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-10-31 14:33+0000\n" +"PO-Revision-Date: 2025-10-31 14:33+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_item.js:0 +msgid "Average amount of t-shirt by order this month" +msgstr "Moyenne du nombre de t-shirt par commande ce mois-ci" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_item.js:0 +msgid "Average time for an order to go from 'new' to 'sent' or 'cancelled'" +msgstr "" + +#. module: awesome_dashboard +#: model:ir.ui.menu,name:awesome_dashboard.menu_root +msgid "Awesome Dashboard" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard.xml:0 +msgid "Customers" +msgstr "" + +#. module: awesome_dashboard +#: model:ir.actions.client,name:awesome_dashboard.dashboard +#: model:ir.ui.menu,name:awesome_dashboard.dashboard_menu +msgid "Dashboard" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_configuration/dashboard_configuration.xml:0 +msgid "Done" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard.xml:0 +msgid "Leads" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_item.js:0 +msgid "Number of cancelled orders this month" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_item.js:0 +msgid "Number of new orders this month" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_item.js:0 +msgid "Shirt orders by size" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_item.js:0 +msgid "Total amount of new orders this month" +msgstr "" + +#. module: awesome_dashboard +#. odoo-javascript +#: code:addons/awesome_dashboard/static/src/dashboard/dashboard_configuration/dashboard_configuration.xml:0 +msgid "Which cards do you wish to see ?" +msgstr "" diff --git a/awesome_dashboard/models/__init__.py b/awesome_dashboard/models/__init__.py new file mode 100644 index 00000000000..8835165330f --- /dev/null +++ b/awesome_dashboard/models/__init__.py @@ -0,0 +1 @@ +from . import res_users diff --git a/awesome_dashboard/models/res_users.py b/awesome_dashboard/models/res_users.py new file mode 100644 index 00000000000..1e1afe47dee --- /dev/null +++ b/awesome_dashboard/models/res_users.py @@ -0,0 +1,30 @@ +from odoo import models, fields, api + + +class ResUsers(models.Model): + _inherit = "res.users" + + average_quantity = fields.Boolean(default=True) + average_time = fields.Boolean(default=True) + nb_new_orders = fields.Boolean(default=True) + nb_cancelled_orders = fields.Boolean(default=True) + total_amount = fields.Boolean(default=True) + orders_by_size = fields.Boolean(default=True) + + @api.model + def get_dashboard_config(self): + user = self.env.user + return { + 'average_quantity': user.average_quantity, + 'average_time': user.average_time, + 'nb_new_orders': user.nb_new_orders, + 'nb_cancelled_orders': user.nb_cancelled_orders, + 'total_amount': user.total_amount, + 'orders_by_size': user.orders_by_size, + } + + @api.model + def set_dashboard_config(self, config): + user = self.env.user + user.write(config) + return True diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js deleted file mode 100644 index c4fb245621b..00000000000 --- a/awesome_dashboard/static/src/dashboard.js +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from "@odoo/owl"; -import { registry } from "@web/core/registry"; - -class AwesomeDashboard extends Component { - static template = "awesome_dashboard.AwesomeDashboard"; -} - -registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..246d5b36866 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,89 @@ +import { Component, useState, onWillStart } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { Layout } from "@web/search/layout"; +import { useService } from "@web/core/utils/hooks"; +import { DashboardItem } from "./dashboard_item"; +import { PieChart } from "./piechart/piechart"; +import { DashboardConfiguration } from "./dashboard_configuration/dashboard_configuration"; + + +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { Layout, DashboardItem, PieChart }; + + setup() { + this.action = useService("action"); + this.dialog = useService("dialog"); + this.orm = useService("orm"); + this.statisticsService = useState(useService("awesome_dashboard.statistics")); + + this.allItems = registry.category("awesome_dashboard").getAll(); + this.state = useState({ + hiddenItems: [] + }); + + onWillStart(async () => { + await this.loadHiddenItems(); + }); + } + + get items() { + return Object.entries(this.allItems).reduce((acc, [itemId, item]) => { + if (!this.state.hiddenItems.includes(item.backend_attribute)) { + acc[itemId] = item; + } + return acc; + }, {}); + } + + async loadHiddenItems() { + try { + const config = await this.orm.call('res.users', 'get_dashboard_config', []); + this.state.hiddenItems = Object.entries(config) + .filter(([_, isVisible]) => !isVisible) + .map(([itemId]) => itemId); + + } catch (e) { + console.error("Failed to load dashboard configuration:", e); + this.state.hiddenItems = []; + } + } + + async saveHiddenItems(hiddenItems) { + try { + const config = Object.values(this.allItems).reduce((acc, item) => { + acc[item.backend_attribute] = !hiddenItems.includes(item.backend_attribute); + return acc; + }, {}); + await this.orm.call('res.users', 'set_dashboard_config', [config]); + this.state.hiddenItems = hiddenItems; + } catch (e) { + console.error("Failed to save dashboard configuration:", e); + } + } + + openConfiguration() { + this.dialog.add(DashboardConfiguration, { + items: this.allItems, + hiddenItems: this.state.hiddenItems, + onSave: async (hiddenItems) => { + await this.saveHiddenItems(hiddenItems); + } + }); + } + + openCustomers() { + this.action.doAction("base.action_partner_form"); + } + + async openLeads() { + await this.action.doAction({ + type: 'ir.actions.act_window', + name: "Leads", + res_model: 'crm.lead', + views: [[false, 'form']], + }); + } +} + +registry.category("lazy_components").add("AwesomeDashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard/dashboard.scss b/awesome_dashboard/static/src/dashboard/dashboard.scss new file mode 100644 index 00000000000..6369827c3a6 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.scss @@ -0,0 +1,11 @@ +.o_dashboard { + background-color: gray; +} + +.item_card { + background-color: white; + border: black solid 1px; + margin: 5px; + padding: 10px; + color: black; +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..0405a9408de --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,26 @@ + + + + + +
+ + + + + +
+
+ + + + + + + + +
+
+
+ +
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_configuration/dashboard_configuration.js b/awesome_dashboard/static/src/dashboard/dashboard_configuration/dashboard_configuration.js new file mode 100644 index 00000000000..6a9d30abf13 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_configuration/dashboard_configuration.js @@ -0,0 +1,44 @@ +import { Component, useState } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; +import { _t } from "@web/core/l10n/translation"; + + +export class DashboardConfiguration extends Component { + static template = "awesome_dashboard.DashboardConfiguration"; + static components = { Dialog }; + static props = { + close: Function, + items: Object, + hiddenItems: Array, + onSave: Function, + }; + + setup() { + const allItems = Object.entries(this.props.items); + + this.state = useState({ + selectedItems: {} + }); + + allItems.forEach(([itemId, item]) => { + this.state.selectedItems[item.backend_attribute] = !this.props.hiddenItems.includes(item.backend_attribute); + }); + } + + toggleItem(backendAttribute) { + this.state.selectedItems[backendAttribute] = !this.state.selectedItems[backendAttribute]; + } + + onApply() { + const hiddenItems = Object.entries(this.state.selectedItems) + .filter(([backendAttribute, isVisible]) => !isVisible) + .map(([backendAttribute]) => backendAttribute); + + this.props.onSave(hiddenItems); + this.props.close(); + } + + getItemDescription(item) { + return item.description || _t("Dashboard item"); + } +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard_configuration/dashboard_configuration.xml b/awesome_dashboard/static/src/dashboard/dashboard_configuration/dashboard_configuration.xml new file mode 100644 index 00000000000..8aa99abf4c3 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_configuration/dashboard_configuration.xml @@ -0,0 +1,33 @@ + + + + + +
+

Which cards do you wish to see ?

+
+ + + +
+ + +
+
+
+
+ + + +
+
+ +
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item.js b/awesome_dashboard/static/src/dashboard/dashboard_item.js new file mode 100644 index 00000000000..8f744c5a362 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item.js @@ -0,0 +1,87 @@ +import { Component } from "@odoo/owl"; +import { PieChart } from "./piechart/piechart"; +import { NumberCard } from "./numbercard/numbercard"; +import { PieChartCard } from "./piechartcard/piechartcard"; +import { registry } from "@web/core/registry"; +import { _t } from "@web/core/l10n/translation"; + + +const dashboardItemsRegistry = registry.category("awesome_dashboard"); + +dashboardItemsRegistry.add("average_quantity", { + backend_attribute: "average_quantity", + description: "Average amout of t-shirt", + Component: NumberCard, + props: (data) => ({ + title: _t("Average amount of t-shirt by order this month"), + value: data.average_quantity, + }) +}); + +dashboardItemsRegistry.add("average_time", { + backend_attribute: "average_time", + description: "Average time for an order", + Component: NumberCard, + size: 2, + props: (data) => ({ + title: _t("Average time for an order to go from 'new' to 'sent' or 'cancelled'"), + value: data.average_time, + }), +}); + +dashboardItemsRegistry.add("nb_new_orders", { + backend_attribute: "nb_new_orders", + description: "Number of new orders this month", + Component: NumberCard, + props: (data) => ({ + title: _t("Number of new orders this month"), + value: data.nb_new_orders, + }), +}); + +dashboardItemsRegistry.add("nb_cancelled_orders", { + backend_attribute: "nb_cancelled_orders", + description: "Number of cancelled orders this month", + Component: NumberCard, + props: (data) => ({ + title: _t("Number of cancelled orders this month"), + value: data.nb_cancelled_orders, + }), +}); + +dashboardItemsRegistry.add("total_amount", { + backend_attribute: "total_amount", + description: "Total amount of new orders this month", + Component: NumberCard, + props: (data) => ({ + title: _t("Total amount of new orders this month"), + value: data.total_amount, + }), +}); + +dashboardItemsRegistry.add("orders_by_size", { + backend_attribute: "orders_by_size", + description: "Shirt orders by size", + Component: PieChartCard, + size: 1.5, + props: (data) => ({ + title: _t("Shirt orders by size"), + values: data.orders_by_size, + }), +}); + +export class DashboardItem extends Component { + static template = "awesome_dashboard.dashboarditem"; + static components = { PieChart } + static props = { + size: { + type: Number, + default: 1, + optional: true, + }, + slots: { + type: Object, + optional: true, + } + } +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item.xml b/awesome_dashboard/static/src/dashboard/dashboard_item.xml new file mode 100644 index 00000000000..0012428f854 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item.xml @@ -0,0 +1,10 @@ + + + + +
+ +
+
+ +
diff --git a/awesome_dashboard/static/src/dashboard/numbercard/numbercard.js b/awesome_dashboard/static/src/dashboard/numbercard/numbercard.js new file mode 100644 index 00000000000..ca8b584a334 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/numbercard/numbercard.js @@ -0,0 +1,13 @@ +import { Component } from "@odoo/owl"; + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + static props = { + title: { + type: String, + }, + value: { + type: Number, + } + } +} diff --git a/awesome_dashboard/static/src/dashboard/numbercard/numbercard.xml b/awesome_dashboard/static/src/dashboard/numbercard/numbercard.xml new file mode 100644 index 00000000000..b433764613e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/numbercard/numbercard.xml @@ -0,0 +1,9 @@ + + + + +

+

+ + + diff --git a/awesome_dashboard/static/src/dashboard/piechart/piechart.js b/awesome_dashboard/static/src/dashboard/piechart/piechart.js new file mode 100644 index 00000000000..0628d9e7e3f --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/piechart/piechart.js @@ -0,0 +1,56 @@ +import { Component, onWillStart, useRef, onMounted, onWillUpdateProps, onWillUnmount } from "@odoo/owl"; +import { loadJS } from "@web/core/assets"; + +export class PieChart extends Component { + static template = "awesome_dashboard.piechart"; + static props = { + data: Object, + }; + + setup() { + this.canvasRef = useRef("canvas"); + this.chart = null; + + onWillStart(async () => { + await loadJS("/web/static/lib/Chart/Chart.js"); + }) + + onMounted(() => { + this.renderChart(); + }); + + onWillUpdateProps(() => { + if (this.chart) { + this.chart.destroy(); + } + this.renderChart(); + }); + + onWillUnmount(() => { + this.chart.destroy(); + }) + } + + renderChart() { + const labels = Object.keys(this.props.data); + const values = Object.values(this.props.data); + + this.chart = new Chart(this.canvasRef.el, { + type: 'pie', + data: { + labels: labels, + datasets: [{ + label: 'Orders by Size', + data: values, + backgroundColor: [ + '#1f77b4', + '#ff7f0e', + '#aec7e8', + ], + borderColor: '#ffffff', + borderWidth: 2 + }] + }, + }); + } +} diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard/piechart/piechart.xml similarity index 55% rename from awesome_dashboard/static/src/dashboard.xml rename to awesome_dashboard/static/src/dashboard/piechart/piechart.xml index 1a2ac9a2fed..a5f0c13910b 100644 --- a/awesome_dashboard/static/src/dashboard.xml +++ b/awesome_dashboard/static/src/dashboard/piechart/piechart.xml @@ -1,8 +1,8 @@ - - hello dashboard + + diff --git a/awesome_dashboard/static/src/dashboard/piechartcard/piechartcard.js b/awesome_dashboard/static/src/dashboard/piechartcard/piechartcard.js new file mode 100644 index 00000000000..91e5dd97ebf --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/piechartcard/piechartcard.js @@ -0,0 +1,15 @@ +import { Component } from "@odoo/owl"; +import { PieChart } from "../piechart/piechart"; + +export class PieChartCard extends Component { + static template = "awesome_dashboard.PieChartCard"; + static components = { PieChart } + static props = { + title: { + type: String, + }, + values: { + type: Object, + }, + } +} diff --git a/awesome_dashboard/static/src/dashboard/piechartcard/piechartcard.xml b/awesome_dashboard/static/src/dashboard/piechartcard/piechartcard.xml new file mode 100644 index 00000000000..ec6319c5861 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/piechartcard/piechartcard.xml @@ -0,0 +1,9 @@ + + + + +

+ + + + diff --git a/awesome_dashboard/static/src/dashboard/statistics_service.js b/awesome_dashboard/static/src/dashboard/statistics_service.js new file mode 100644 index 00000000000..7760c345606 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/statistics_service.js @@ -0,0 +1,23 @@ +import { registry } from "@web/core/registry"; +import { reactive } from "@odoo/owl"; +import { rpc } from "@web/core/network/rpc"; + +const statisticsService = { + start() { + const statistics = reactive({ data: null }); + + const loadStatistics = async () => { + statistics.data = await rpc("/awesome_dashboard/statistics"); + }; + + loadStatistics(); + + setInterval(() => { + loadStatistics(); + }, 10000); + + return statistics; + }, +}; + +registry.category("services").add("awesome_dashboard.statistics", statisticsService); diff --git a/awesome_dashboard/static/src/dashboard_loader.js b/awesome_dashboard/static/src/dashboard_loader.js new file mode 100644 index 00000000000..58e4c0eebcc --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_loader.js @@ -0,0 +1,12 @@ +import { registry } from "@web/core/registry"; +import { LazyComponent } from "@web/core/assets"; +import { Component, xml } from "@odoo/owl"; + +class AwesomeDashboardLoader extends Component { + static components = { LazyComponent }; + static template = xml` + + `; +} + +registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboardLoader); diff --git a/awesome_dashboard/views/res_users_views.xml b/awesome_dashboard/views/res_users_views.xml new file mode 100644 index 00000000000..3717d41f5b6 --- /dev/null +++ b/awesome_dashboard/views/res_users_views.xml @@ -0,0 +1,22 @@ + + + + + res.users.view.form.inherit.awesome_dashboard + res.users + + + + + + + + + + + + + + + + diff --git a/awesome_owl/__manifest__.py b/awesome_owl/__manifest__.py index e8ac1cda552..600a73bf97a 100644 --- a/awesome_owl/__manifest__.py +++ b/awesome_owl/__manifest__.py @@ -29,11 +29,15 @@ 'assets': { 'awesome_owl.assets_playground': [ ('include', 'web._assets_helpers'), + ('include', 'web._assets_frontend_helpers'), 'web/static/src/scss/pre_variables.scss', 'web/static/lib/bootstrap/scss/_variables.scss', - ('include', 'web._assets_bootstrap'), - ('include', 'web._assets_core'), + 'web/static/lib/bootstrap/scss/_variables-dark.scss', + 'web/static/lib/bootstrap/scss/_maps.scss', + ('include', 'web._assets_bootstrap_frontend'), 'web/static/src/libs/fontawesome/css/font-awesome.css', + 'web/static/src/scss/fontawesome_overridden.scss', + ('include', 'web._assets_core'), 'awesome_owl/static/src/**/*', ], }, diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..ffcd78ea299 --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,21 @@ +import { Component, useState } from "@odoo/owl"; + + +export class Card extends Component { + static template = "awesome_owl.card"; + static props = { + title: String, + slots: { + type: Object, + optional: true, + }, + }; + + setup() { + this.state = useState({ isToggled: false }); + } + + toggle() { + this.state.isToggled = !this.state.isToggled; + } +} diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 00000000000..b2b5a846bfb --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,15 @@ + + + + +

+
+
+
+ +
+
+
+
+ +
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..7f6f1bae8bb --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,22 @@ +import { Component, useState } from "@odoo/owl"; + + +export class Counter extends Component { + static template = "awesome_owl.counter"; + static defaultProps = { + buttonText: "Increment", + } + static props = { + buttonText: { type: String, optional: true, }, + onChange: { type: Function, optional: true }, + }; + + setup() { + this.state = useState({ value: 1 }); + } + + increment() { + this.state.value++; + this.props.onChange?.(); + } +} diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml new file mode 100644 index 00000000000..470f0178441 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,11 @@ + + + + +
+ Counter: +
+
+ +
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 4ac769b0aa5..7c0420c67e2 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,5 +1,19 @@ -import { Component } from "@odoo/owl"; +import { Component, useState } from "@odoo/owl"; +import { Counter } from "./counter/counter"; +import { Card } from "./card/card"; +import { TodoList } from "./todo/todolist" + export class Playground extends Component { static template = "awesome_owl.playground"; + + static components = { Counter, Card, TodoList }; + + setup() { + this.state = useState({ value : 2 }); + } + + incrementSum() { + this.state.value++; + } } diff --git a/awesome_owl/static/src/playground.scss b/awesome_owl/static/src/playground.scss new file mode 100644 index 00000000000..f4206b9637c --- /dev/null +++ b/awesome_owl/static/src/playground.scss @@ -0,0 +1,6 @@ +.chapter_tuto { + border: solid; + padding: 10px; + border-radius: 5px; + margin-bottom: 5px +} diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..cbac5159a5e 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -2,9 +2,29 @@ -
+
hello world
+ +
+ + +

The sum id:

+
+ +
+ +

Some content

+
+ + + + +
+ +
+ +
diff --git a/awesome_owl/static/src/todo/todoitem.js b/awesome_owl/static/src/todo/todoitem.js new file mode 100644 index 00000000000..e2f1d7023bb --- /dev/null +++ b/awesome_owl/static/src/todo/todoitem.js @@ -0,0 +1,27 @@ +import { Component } from "@odoo/owl"; + + +export class TodoItem extends Component { + static template = "awesome_owl.todoitem"; + static props = { + id: Number, + description: String, + isCompleted: Boolean, + onToggle: { + Function, + optional: true, + }, + onClick: { + Function, + optional: true, + }, + }; + + toggleState() { + this.props.onToggle(this.props.id); + } + + removeTodo() { + this.props.onClick(this.props.id); + } +} diff --git a/awesome_owl/static/src/todo/todoitem.xml b/awesome_owl/static/src/todo/todoitem.xml new file mode 100644 index 00000000000..867f89fff73 --- /dev/null +++ b/awesome_owl/static/src/todo/todoitem.xml @@ -0,0 +1,13 @@ + + + + +

+ + . + + +

+
+ +
diff --git a/awesome_owl/static/src/todo/todolist.js b/awesome_owl/static/src/todo/todolist.js new file mode 100644 index 00000000000..05775dc4179 --- /dev/null +++ b/awesome_owl/static/src/todo/todolist.js @@ -0,0 +1,39 @@ +import { Component, useState, useRef } from "@odoo/owl"; +import { TodoItem } from "./todoitem" +import { useAutofocus } from "@awesome_owl/utils" + + +export class TodoList extends Component { + static template = "awesome_owl.todolist"; + static components = { TodoItem }; + + setup() { + this.todos = useState([]); + this.nextId = 0; + this.description_inputRef = useRef("todo_input"); + useAutofocus(this.description_inputRef); + } + + addTodo(ev) { + const description = this.description_inputRef.el.value.trim(); + if (ev.keyCode === 13 && description) { + this.todos.push({ id: this.nextId, description: description, isCompleted: false }); + this.nextId++; + this.description_inputRef.el.value = ""; + } + } + + toggleTodo(id) { + const todo = this.todos.find(t => t.id === id); + if (todo) { + todo.isCompleted = !todo.isCompleted; + } + } + + removeTodo(id) { + const index = this.todos.findIndex(t => t.id === id); + if (index >= 0) { + this.todos.splice(index, 1); + } + } +} diff --git a/awesome_owl/static/src/todo/todolist.xml b/awesome_owl/static/src/todo/todolist.xml new file mode 100644 index 00000000000..185f85b00d3 --- /dev/null +++ b/awesome_owl/static/src/todo/todolist.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..5c0a897c728 --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,13 @@ +import { useEffect } from "@odoo/owl"; + + +export function useAutofocus(ref) { + useEffect( + () => { + if (ref.el) { + ref.el.focus(); + } + }, + () => [ref.el] + ); +} diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..647a95ef2f3 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,23 @@ +{ + "name": "Real Estate", + "version": "1.0", + "summary": "Manage properties and buyers.", + "license": "OEEL-1", + "depends": [ + "base", + ], + "author": "wimar-odoo", + "description": """ + Manage efficiently real estate properties for sale and match them with potential buyers. + """, + "application": True, + "data": [ + "security/ir.model.access.csv", + "views/estate_property_views.xml", + "views/estate_property_type_views.xml", + "views/estate_property_tag_views.xml", + "views/estate_property_offer_views.xml", + "views/res_users_views.xml", + "views/estate_menus.xml", + ], +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..9a2189b6382 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,5 @@ +from . import estate_property +from . import estate_property_type +from . import estate_property_tag +from . import estate_property_offer +from . import res_users diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..c796d6c062f --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,92 @@ +from odoo import fields, models, api, _ +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_is_zero, float_compare + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Estate Property" + _order = "id DESC" + + name = fields.Char(string="Title", required=True) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date(string="Available From", copy=False, default=fields.Date.add(fields.Date.today(), months=3)) + expected_price = fields.Float(required=True) + selling_price = fields.Float(readonly=True, copy=False) + bedrooms = fields.Integer(default=2) + living_area = fields.Integer(string="Living Area (sqm)") + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer(string="Garden Area (sqm)") + garden_orientation = fields.Selection( + selection=[("north", "North"), ("south", "South"), ("east", "East"), ("west", "West")], + help="The orientation of the Garden", + ) + active = fields.Boolean(default=True) + state = fields.Selection( + selection=[("new", "New"), ("received", "Offer Received"), ("accepted", "Offer Accepted"), ("sold", "Sold"), ("cancelled", "Cancelled")], + required=True, + copy=False, + default="new", + help="The current state of the property", + ) + property_type_id = fields.Many2one("estate.property.type") + salesperson_id = fields.Many2one("res.users", string="Salesman", default=lambda self: self.env.user) + buyer_id = fields.Many2one("res.partner", copy=False) + tag_ids = fields.Many2many("estate.property.tag") + offer_ids = fields.One2many("estate.property.offer", "property_id") + total_area = fields.Integer(compute="_compute_total_area") + best_price = fields.Float(string="Best offer", compute="_compute_best_price") + + _positive_expected_price = models.Constraint( + "CHECK(expected_price > 0)", + "The expected price must be strictly positive.", + ) + + @api.depends("living_area", "garden_area") + def _compute_total_area(self): + for record in self: + record.total_area = record.living_area + record.garden_area + + @api.depends("offer_ids") + def _compute_best_price(self): + for record in self: + if record.offer_ids: + record.best_price = max(record.offer_ids.mapped("price")) + else: + record.best_price = 0.0 + + @api.onchange("garden") + def _onchange_garden(self): + if self.garden: + self.garden_area = 10 + self.garden_orientation = "north" + else: + self.garden_area = 0 + self.garden_orientation = "" + + def action_property_sold(self): + if self.filtered(lambda x: x.state == "cancelled"): + raise UserError(_("Cancelled properties cannot be sold.")) + self.state = "sold" + return True + + def action_property_cancel(self): + if self.filtered(lambda x: x.state == "sold"): + raise UserError(_("Sold properties cannot be cancelled.")) + self.state = "cancelled" + return True + + @api.constrains("selling_price", "expected_price") + @api.onchange("selling_price", "expected_price") + def _check_ninety_percent(self): + for record in self: + if not float_is_zero(record.selling_price, 2) and float_compare(record.expected_price * 0.9, record.selling_price, 2) == 1: + raise ValidationError(_("The selling price must be at least 90% of the expected price! You must reduce the expected price if you want to accept this offer.")) + + @api.ondelete(at_uninstall=False) + def _unlink_except_new_and_cancelled(self): + if self.filtered(lambda x: x.state not in ("new", "cancelled")): + raise UserError(_("Properties need to be new or cancelled to be unlinked.")) diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..88d5c980421 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,58 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Estate Property Offer" + _order = "price DESC" + + price = fields.Float() + status = fields.Selection( + selection=[("accepted", "Accepted"), ("refused", "Refused")], + copy=False, + ) + partner_id = fields.Many2one("res.partner", string="Buyer", required=True) + property_id = fields.Many2one("estate.property", required=True) + validity = fields.Integer(string="Validity (days)", default=7) + date_deadline = fields.Date(string="Deadline", compute="_compute_date_deadline", inverse="_inverse_date_deadline") + property_type_id = fields.Many2one("estate.property.type", related="property_id.property_type_id", store=True) + + @api.depends("create_date", "validity") + def _compute_date_deadline(self): + for record in self: + record.date_deadline = fields.Date.add(record.create_date or fields.Date.today(), days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + record.validity = (record.date_deadline - record.create_date.date()).days + + def action_accept_offer(self): + if self.filtered(lambda x: x.property_id.state in ("accepted", "sold", "cancelled")): + raise UserError(_("This offer can't be accepted because the property is currently %s.", self.property_id.state)) + + self.status = "accepted" + self.property_id.state = "accepted" + self.property_id.selling_price = self.price + self.property_id.buyer_id = self.partner_id + + return True + + def action_refuse_offer(self): + self.status = "refused" + return True + + _positive_offer_price = models.Constraint( + "CHECK(price > 0)", + "The offer price must be strictly positive.", + ) + + @api.model_create_multi + def create(self, vals_list): + properties = self.env["estate.property"].browse([vals["property_id"] for vals in vals_list]) + for vals in vals_list: + filtered_properties = properties.filtered(lambda p: p.id == vals["property_id"]) + if vals["price"] <= filtered_properties.best_price: + raise UserError(_("The offer price must be strictly greater than the current best offer.")) + filtered_properties.state = "received" + return super().create(vals_list) diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..066b582f05f --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,15 @@ +from odoo import models, fields + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Estate Property Tag" + _order = "name" + + name = fields.Char(required=True) + color = fields.Integer() + + _unique_property_tag_name = models.Constraint( + "UNIQUE(name)", + "The tag name must be unique.", + ) diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..1a53a4d26c1 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,23 @@ +from odoo import models, fields, api + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Estate Property Type" + _order = "name, sequence" + + name = fields.Char(required=True) + property_ids = fields.One2many("estate.property", "property_type_id") + sequence = fields.Integer(default=1) + offer_ids = fields.One2many("estate.property.offer", "property_type_id") + offer_count = fields.Integer(compute="_compute_offer_count") + + _unique_property_type_name = models.Constraint( + "UNIQUE(name)", + "The tag name must be unique.", + ) + + @api.depends("offer_ids") + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..ddefee38646 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,7 @@ +from odoo import models, fields + + +class ResUsers(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many("estate.property", "salesperson_id") diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..826a96a7eb7 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,6 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property_user,access_estate_property_user,model_estate_property,base.group_user,1,1,1,1 +access_estate_property_type_user,access_estate_property_type_user,model_estate_property_type,base.group_user,1,1,1,1 +access_estate_property_tag_user,access_estate_property_tag_user,model_estate_property_tag,base.group_user,1,1,1,1 +access_estate_property_offer_user,access_estate_property_offer_user,model_estate_property_offer,base.group_user,1,1,1,1 +access_res_users_user,access_res_users_user,model_res_users,base.group_user,1,1,1,1 diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..6a6329257c5 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..a467f73818a --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,36 @@ + + + + estate.property.offer.form + estate.property.offer + +
+ + + + + + + + + +
+
+
+ + + estate.property.offer.list + estate.property.offer + + + + + + + +
+ + +

+ +

+ + + + + + + + + + + +
+ + + + diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..5e342c8e683 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,129 @@ + + + + Properties + estate.property + list,form,kanban + {"search_default_state": True} + + + + estate.property.list + estate.property + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+
+
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + estate.property.kanban + estate.property + + + + + +
+

+ +

+ Expected Price: +
+ Best Offer:
+
+
+ Selling Price:
+
+ +
+
+
+
+
+
+ + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + +
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..da370163e0a --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,15 @@ + + + + res.users.view.form.inherit.estate + res.users + + + + + + + + + + diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..2f79489a775 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,16 @@ +{ + "name": "Estate Account", + "version": "19.0.1.0.0", + "summary": "Manage properties invoices", + "description": "Manage properties invoices", + "author": "wimar-odoo", + "license": "OEEL-1", + "depends": [ + "estate", + "account", + ], + "application": True, + "data": [ + + ], +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..5e1963c9d2f --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 00000000000..732f739d968 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,32 @@ +from odoo import models, Command + + +class EstateProperty(models.Model): + _inherit = "estate.property" + + def action_property_sold(self): + values = { + "partner_id": self.salesperson_id.id, + "move_type": "out_invoice", + "journal_id": self.env["account.journal"].search([('type', '=', 'sale')], limit=1).id, + "invoice_line_ids": [ + Command.create({ + "name": self.name, + "quantity": 1, + "price_unit": self.selling_price, + }), + Command.create({ + "name": "6% commission", + "quantity": 1, + "price_unit": self.selling_price * 0.06, + }), + Command.create({ + "name": "Administrative fees", + "quantity": 1, + "price_unit": 100.00, + }) + ], + } + + self.env["account.move"].create(values) + return super().action_property_sold() diff --git a/estate_account/security/ir.model.access.csv b/estate_account/security/ir.model.access.csv new file mode 100644 index 00000000000..a6992df6d34 --- /dev/null +++ b/estate_account/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property_user,access_estate_property_user,model_estate_property,base.group_user,1,1,1,1