From e91f1f3ad1230693b0b3cd10e76a23039c4b93a4 Mon Sep 17 00:00:00 2001 From: niyp-odoo Date: Wed, 2 Jul 2025 17:42:54 +0530 Subject: [PATCH 01/15] [ADD] estate: Initial module This commit introduces the new estate module, designed to manage real estate properties within the Odoo environment. It includes the creation of property-related models, basic views, menus, and initial security access rights. The module lays the foundation for further enhancements such as property listings, offers, and tag management. --- estate/__init__.py | 0 estate/__manifest__.py | 6 ++++++ 2 files changed, 6 insertions(+) create mode 100644 estate/__init__.py create mode 100644 estate/__manifest__.py diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..95f73109275 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,6 @@ +{ + 'name': 'Estate', + 'installable': True, + 'application': True, + 'auto_install': False +} \ No newline at end of file From e166706417dde5da70f08c1eec6ce890e600d406 Mon Sep 17 00:00:00 2001 From: niyp-odoo Date: Thu, 3 Jul 2025 10:53:42 +0530 Subject: [PATCH 02/15] [IMP] estate: Add fields to estate models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added new fields to various models within the estate module to support extended functionality and data capture. These additions improve the module’s ability to store and manage detailed property-related information in alignment with business requirements. --- estate/__init__.py | 1 + estate/models/__init__.py | 1 + estate/models/estate_property.py | 24 ++++++++++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 estate/models/__init__.py create mode 100644 estate/models/estate_property.py diff --git a/estate/__init__.py b/estate/__init__.py index e69de29bb2d..9a7e03eded3 100644 --- a/estate/__init__.py +++ b/estate/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..f4c8fd6db6d --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property \ No newline at end of file diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..befdaed698f --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,24 @@ +from odoo import fields,models + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Estate Property is defined" + + name = fields.Char(required=True) + description = fields.Text() + postcode = fields.Char() + date_avaiblity = fields.Date() + expected_price = fields.Float(required=True) + selling_price = fields.Float() + bedrooms = fields.Integer() + living_area = fields.Integer() + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer() + garden_orientation = fields.Selection( + string = 'Type', + selection=[('north','North'), ('south','South'), ('east','East'), ('west','West')], + help = "Orientation is used to locate garden's direction" + ) \ No newline at end of file From 2616bf1c925cf704037cb23ecc05e929164743e5 Mon Sep 17 00:00:00 2001 From: niyp-odoo Date: Fri, 4 Jul 2025 10:06:27 +0530 Subject: [PATCH 03/15] [IMP] estate: added unit tests This commit adds test cases to validate the key workflows in the estate module, including property creation, status transitions, and offer handling.These tests ensure better coverage and help prevent regressions during future development. --- estate/__manifest__.py | 8 ++- estate/models/estate_property.py | 20 ++++-- estate/security/ir.model.access.csv | 2 + estate/views/estate_menus.xml | 9 +++ estate/views/estate_property_views.xml | 95 ++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 estate/security/ir.model.access.csv create mode 100644 estate/views/estate_menus.xml create mode 100644 estate/views/estate_property_views.xml diff --git a/estate/__manifest__.py b/estate/__manifest__.py index 95f73109275..a3b414e742a 100644 --- a/estate/__manifest__.py +++ b/estate/__manifest__.py @@ -2,5 +2,11 @@ 'name': 'Estate', 'installable': True, 'application': True, - 'auto_install': False + 'auto_install': False, + 'data':[ + 'security/ir.model.access.csv', + 'views/estate_property_views.xml', + 'views/estate_menus.xml', + ], + 'license': 'LGPL-3', } \ No newline at end of file diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py index befdaed698f..83f3985896d 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -1,5 +1,6 @@ from odoo import fields,models - +from datetime import date +from dateutil.relativedelta import relativedelta class EstateProperty(models.Model): _name = "estate.property" @@ -8,17 +9,24 @@ class EstateProperty(models.Model): name = fields.Char(required=True) description = fields.Text() postcode = fields.Char() - date_avaiblity = fields.Date() + date_avaiblity = fields.Date(copy = False, default = date.today()+ relativedelta(months=3)) expected_price = fields.Float(required=True) - selling_price = fields.Float() - bedrooms = fields.Integer() + selling_price = fields.Float(readonly = True, copy = False) + bedrooms = fields.Integer(default = 2) living_area = fields.Integer() facades = fields.Integer() garage = fields.Boolean() garden = fields.Boolean() garden_area = fields.Integer() garden_orientation = fields.Selection( - string = 'Type', + string = 'Direction', selection=[('north','North'), ('south','South'), ('east','East'), ('west','West')], - help = "Orientation is used to locate garden's direction" + help = "This is used to locate garden's direction" + ) + active = fields.Boolean(default = True) + state = fields.Selection( + selection=[('new','New'), ('offer received','Offer Received'), ('offer acceptedt','Offer Accepted'), ('sold','Sold'), ('cancelled', 'Cancelled')], + default = 'new', + required = True, + copy = False, ) \ No newline at end of file diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..ab63520e22b --- /dev/null +++ b/estate/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 +estate.access_estate_property,access_estate_property,estate.model_estate_property,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..3abf7a00c5d --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..038a369cf61 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,95 @@ + + + + Properties + estate.property + list,form + + + + estate.property + estate.property + + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file From e6bd1e673a8ca197974077f08842ed5a6dd4d806 Mon Sep 17 00:00:00 2001 From: niyp-odoo Date: Mon, 7 Jul 2025 10:09:49 +0530 Subject: [PATCH 04/15] [IMP] estate: Improve user interface and layout Enhanced the user interface of the estate module by refining form views, improving layout structure, and aligning elements for better usability. These changes provide a cleaner and more intuitive experience for users managing property-related data. --- estate/__manifest__.py | 3 + estate/models/__init__.py | 5 +- estate/models/estate_property.py | 70 +++++++++++- estate/models/estate_property_offer.py | 66 ++++++++++++ estate/models/estate_property_tag.py | 11 ++ estate/models/estate_property_type.py | 10 ++ estate/security/ir.model.access.csv | 5 +- estate/views/estate_menus.xml | 17 +-- estate/views/estate_property_tag_views.xml | 18 ++++ estate/views/estate_property_type_views.xml | 18 ++++ estate/views/estate_property_views.xml | 113 ++++++++++++++------ 11 files changed, 295 insertions(+), 41 deletions(-) create mode 100644 estate/models/estate_property_offer.py create mode 100644 estate/models/estate_property_tag.py create mode 100644 estate/models/estate_property_type.py create mode 100644 estate/views/estate_property_tag_views.xml create mode 100644 estate/views/estate_property_type_views.xml diff --git a/estate/__manifest__.py b/estate/__manifest__.py index a3b414e742a..50a1bc8539c 100644 --- a/estate/__manifest__.py +++ b/estate/__manifest__.py @@ -6,7 +6,10 @@ '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_menus.xml', + ], 'license': 'LGPL-3', } \ No newline at end of file diff --git a/estate/models/__init__.py b/estate/models/__init__.py index f4c8fd6db6d..09b2099fe84 100644 --- a/estate/models/__init__.py +++ b/estate/models/__init__.py @@ -1 +1,4 @@ -from . import estate_property \ No newline at end of file +from . import estate_property +from . import estate_property_type +from . import estate_property_tag +from . import estate_property_offer \ No newline at end of file diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py index 83f3985896d..9d7ead8880f 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -1,6 +1,7 @@ -from odoo import fields,models +from odoo import fields,models, api from datetime import date from dateutil.relativedelta import relativedelta +from odoo.exceptions import UserError, ValidationError class EstateProperty(models.Model): _name = "estate.property" @@ -25,8 +26,71 @@ class EstateProperty(models.Model): ) active = fields.Boolean(default = True) state = fields.Selection( - selection=[('new','New'), ('offer received','Offer Received'), ('offer acceptedt','Offer Accepted'), ('sold','Sold'), ('cancelled', 'Cancelled')], + selection=[('new','New'), ('offer received','Offer Received'), ('offer accepted','Offer Accepted'), ('sold','Sold'), ('cancelled', 'Cancelled')], default = 'new', required = True, copy = False, - ) \ No newline at end of file + ) + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + salesman_id = fields.Many2one('res.users', string='Salesman', index=True, default=lambda self: self.env.user) + buyer_id = fields.Many2one( + 'res.partner', + string='Buyer', + index=True, + tracking=True, + default=lambda self: self.env.user.partner_id.id +) + _sql_constraints = [ + ('check_expectep_price', 'CHECK(expected_price > 0 AND selling_price > 0)', + 'The Price must be positve.') + ] + + property_tag_ids = fields.Many2many("estate.property.tag") + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + + total_area = fields.Float(compute="_compute_total") + + @api.depends("garden_area","living_area") + def _compute_total(self): + for record in self: + record.total_area = record.garden_area + record.living_area + + best_price = fields.Float(compute="_compute_best_price", string="Best Offer Price" ,readonly = True) + + @api.depends("offer_ids.price") + 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 = False + + def action_cancel(self): + for record in self: + if record.state == 'sold': + raise UserError("Sold properties cannot be cancelled.") + record.state = 'cancelled' + + # SOLD button logic + def action_sold(self): + for record in self: + if record.state == 'cancelled': + raise UserError("Cancelled properties cannot be marked as sold.") + record.state = 'sold' + + + @api.constrains('selling_price') + def _check_price(self): + for record in self: + if record.selling_price >= record.expected_price *0.9: + raise ValidationError("The selling price cannot be lower than 90% of the expected price.") + \ No newline at end of file diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..062467735fa --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,66 @@ +from datetime import timedelta +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Offers related to property are made" + + price = fields.Float() + status = fields.Selection( + selection=[('accepted', 'Accepted'), ('refused', 'Refuse')], + copy = False, + ) + partner_id = fields.Many2one( + "res.partner",string='Partner',index = True, default = lambda self: self.env.user.partner_id.id +) + property_id = fields.Many2one("estate.property",index = True, required = True) + validity = fields.Integer(default = 7) + date_deadline = fields.Date( + string="Deadline", + compute="_compute_date_deadline", + inverse="_inverse_date_deadline", + store=True + ) + + _sql_constraints = [ + ('check_price', 'CHECK(price > 0)', 'The offer price must be greater than 0') + ] + + @api.depends('create_date', 'validity') + def _compute_date_deadline(self): + for record in self: + # Use create_date if available, otherwise fallback to today + create_date = record.create_date or fields.Date.today() + record.date_deadline = create_date + timedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + create_date = record.create_date or fields.Date.today() + if record.date_deadline: + record.validity = (record.date_deadline.day - create_date.day) + + def action_confirm(self): + for offer in self: + # Check if already accepted offer exists for the property + existing_offer = offer.property_id.offer_ids.filtered(lambda o: o.status == 'accepted' and o.id != offer.id) + if existing_offer: + raise UserError("Only one offer can be accepted per property.") + + # Set accepted + offer.status = 'accepted' + # Set buyer and selling price on the property + offer.property_id.buyer_id = offer.partner_id + offer.property_id.selling_price = offer.price + offer.property_id.state = 'offer accepted' + + + + def action_refuse(self): + for record in self: + record.status = 'refused' + + + + \ No newline at end of file diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..46957f77a31 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,11 @@ +from odoo import fields,models + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Tags of estate are defined" + + name = fields.Char(required = True) + + _sql_constraints = [ + ('check_unique_name', 'UNIQUE(name)', 'The name of the tag should be unique') + ] \ No newline at end of file diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..02ebe0c5c61 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,10 @@ +from odoo import fields,models + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Type of estate is defined" + + name = fields.Char(required = True) + _sql_constraints = [ + ('check_unique_property_type', 'UNIQUE(name)', 'The Property type should be unique') + ] \ No newline at end of file diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv index ab63520e22b..0c0b62b7fee 100644 --- a/estate/security/ir.model.access.csv +++ b/estate/security/ir.model.access.csv @@ -1,2 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -estate.access_estate_property,access_estate_property,estate.model_estate_property,base.group_user,1,1,1,1 \ No newline at end of file +estate.access_estate_property,access_estate_property,estate.model_estate_property,base.group_user,1,1,1,1 +estate.access_estate_property_type,access_estate_property_type,estate.model_estate_property_type,base.group_user,1,1,1,1 +estate.access_estate_property_tag,access_estate_property_tag,estate.model_estate_property_tag,base.group_user,1,1,1,1 +estate.access_estate_property_offer,access_estate_property_offer,estate.model_estate_property_offer,base.group_user,1,1,1,1 diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml index 3abf7a00c5d..e56576d4cbd 100644 --- a/estate/views/estate_menus.xml +++ b/estate/views/estate_menus.xml @@ -1,9 +1,14 @@ - - - - - - \ No newline at end of file + + + + + + + + + + + diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml new file mode 100644 index 00000000000..a1591e9f00a --- /dev/null +++ b/estate/views/estate_property_tag_views.xml @@ -0,0 +1,18 @@ + + + + Property Tags + estate.property.tag + list,form + + + + estate.property.tag.search + estate.property.tag + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml new file mode 100644 index 00000000000..e7bdf5db8c8 --- /dev/null +++ b/estate/views/estate_property_type_views.xml @@ -0,0 +1,18 @@ + + + + Property Types + estate.property.type + list,form + + + + estate.property.type.search + estate.property.type + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml index 038a369cf61..3e045dab5fd 100644 --- a/estate/views/estate_property_views.xml +++ b/estate/views/estate_property_views.xml @@ -1,16 +1,19 @@ + + - Properties - estate.property - list,form + Properties + estate.property + list,form + estate.property estate.property - + @@ -22,20 +25,42 @@ - - + estate.property.form estate.property
+
+
+

- +

- - - + + + + + + + + + + + + @@ -46,21 +71,48 @@ + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + +

+ +

+ + + + + + + + + + + + + + +
+
+
@@ -15,4 +71,22 @@ + + + + + + estate.property.offer.tree + estate.property.offer + + + + + + + + + + +
\ No newline at end of file diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml index 3a7d9d85bfc..febc6191443 100644 --- a/estate/views/estate_property_views.xml +++ b/estate/views/estate_property_views.xml @@ -6,6 +6,7 @@ Properties estate.property list,form + {'search_default_state': 1} @@ -13,14 +14,16 @@ estate.property estate.property - - - - - + + + + + + + - - + + @@ -36,13 +39,15 @@ string="Cancel" type="object" class="btn btn-secondary" - xoptions="{'invisible': [('state', 'in', ['cancelled', 'sold'])]}"/> + invisible= "state == 'cancelled' or state == 'sold'"/> + +

+ +

+ + + + \ No newline at end of file diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..88d13ae6aa6 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,21 @@ +import {Component, useState} from "@odoo/owl" + + +export class Counter extends Component{ + static template = "awesome_owl.Counter"; + + static props = { + onChange: { type: Function, optional: true } + } + + setup(){ + this.state = useState({value: 1}); + } + + increment(){ + this.state.value++; + if (this.props.onChange) { + this.props.onChange(this.state.value); + } + } +} diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml new file mode 100644 index 00000000000..17ce51491cf --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,9 @@ + + + +
+

Counter:

+ +
+
+
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 657fb8b07bb..c9f96c2b213 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,7 +1,20 @@ /** @odoo-module **/ -import { Component } from "@odoo/owl"; +import { useState, markup, Component } 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({ sum: 2 }); + } + + incrementSum(value) { + this.state.sum += 1; + } + content1 = "
some content
" + content2 = markup("
some content
") } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..c28dae27241 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -1,10 +1,24 @@ -
- hello world + hello world + + +
+
+

The Sum is:

+
+
+ +

This is the content of Card 1.

+
+ + + +
+
+
-
diff --git a/awesome_owl/static/src/todo/todoitem.js b/awesome_owl/static/src/todo/todoitem.js new file mode 100644 index 00000000000..b274f3726df --- /dev/null +++ b/awesome_owl/static/src/todo/todoitem.js @@ -0,0 +1,20 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class TodoItem extends Component { + static template = "awesome_owl.TodoItem"; + static props = { + todo: { + type: Object, + shape: { + id: Number, + description: String, + isCompleted: Boolean, + }, + optional: false, + }, + toggleState: Function, + removeTodo: Function, + }; +} diff --git a/awesome_owl/static/src/todo/todoitem.xml b/awesome_owl/static/src/todo/todoitem.xml new file mode 100644 index 00000000000..2a71f8bb27f --- /dev/null +++ b/awesome_owl/static/src/todo/todoitem.xml @@ -0,0 +1,15 @@ + + + +
+ + . + + +
+
+
diff --git a/awesome_owl/static/src/todo/todolist.js b/awesome_owl/static/src/todo/todolist.js new file mode 100644 index 00000000000..8c8b93d2304 --- /dev/null +++ b/awesome_owl/static/src/todo/todolist.js @@ -0,0 +1,55 @@ +/** @odoo-module **/ + +import { Component, useState } from "@odoo/owl"; +import { TodoItem } from "./todoitem"; +import { useAutofocus } from "../utils/utils"; + +export class TodoList extends Component { + static template = "awesome_owl.TodoList"; + static components = { TodoItem }; + + setup() { + this.state = useState({ + text: "", + todos: [], + nextId: 1, + }); + this.inputRef = useAutofocus("input"); + this.toggleState = this.toggleState.bind(this); + this.removeTodo = this.removeTodo.bind(this); + + + } + + addTodo(ev) { + if (ev.key === "Enter") { + const description = this.state.text.trim(); + if (!description) return; + + this.state.todos.push({ + id: this.state.nextId++, + description: description, + isCompleted: false, + }); + this.state.text = ""; + } + } + + toggleState(todoId) { + const todo = this.state.todos.find((t) => t.id === todoId); + if (todo) { + todo.isCompleted = !todo.isCompleted; + } + } + + removeTodo(todoId) { + const index = this.state.todos.findIndex((t) => t.id === todoId); + if (index >= 0) { + this.state.todos.splice(index, 1); + for (let i = index; i < this.state.todos.length; i++) { + this.state.todos[i].id = i + 1; + } + } + this.state.nextId = this.state.todos.length + 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..c6172408e5c --- /dev/null +++ b/awesome_owl/static/src/todo/todolist.xml @@ -0,0 +1,20 @@ + + + +
+

Todo List

+ +
+ +
+ +
+
+
diff --git a/awesome_owl/static/src/utils/utils.js b/awesome_owl/static/src/utils/utils.js new file mode 100644 index 00000000000..4ebcaa1a7e6 --- /dev/null +++ b/awesome_owl/static/src/utils/utils.js @@ -0,0 +1,13 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export function useAutofocus(input) { + const inputRef = useRef(input); + + onMounted(() => { + if (inputRef.el) { + inputRef.el.focus(); + } + }); + + return { inputRef }; +} From ff610ad59221cf00d939a91674f30417d39b5a4d Mon Sep 17 00:00:00 2001 From: niyp-odoo Date: Fri, 18 Jul 2025 18:12:19 +0530 Subject: [PATCH 15/15] [ADD] awesome_dashboard: implement custom dashboard module Developed a custom dashboard module using OWL and JavaScript to display key business metrics in a dynamic and user-friendly interface. The dashboard integrates with backend models to show real-time data using cards and charts.This module demonstrates how to build interactive dashboards in Odoo using client-side rendering and custom components. --- awesome_dashboard/static/src/dashboard.js | 10 --- awesome_dashboard/static/src/dashboard.xml | 8 -- .../static/src/dashboard/dashboard.js | 78 +++++++++++++++++++ .../static/src/dashboard/dashboard.xml | 28 +++++++ .../dashboard/dashboardItem/dashboarditem.js | 11 +++ .../dashboard/dashboardItem/dashboarditem.xml | 10 +++ .../dashboardSetting/dashboardsetting.js | 58 ++++++++++++++ .../dashboardSetting/dashboardsetting.xml | 26 +++++++ .../static/src/dashboard/dashbord.scss | 40 ++++++++++ .../static/src/dashboard/dashbord_item.js | 70 +++++++++++++++++ .../src/dashboard/numbercard/numbercard.js | 17 ++++ .../src/dashboard/numbercard/numbercard.xml | 16 ++++ .../static/src/dashboard/piechart/piechart.js | 69 ++++++++++++++++ .../src/dashboard/piechart/piechart.xml | 6 ++ .../dashboard/piechartcard/piechartcard.js | 24 ++++++ .../dashboard/piechartcard/piechartcard.xml | 22 ++++++ .../static/src/dashboard/statistics.js | 38 +++++++++ .../static/src/dashboard_action.js | 12 +++ awesome_owl/static/src/card/card.xml | 2 +- awesome_owl/static/src/playground.js | 4 +- awesome_owl/static/src/todo/todolist.xml | 1 - 21 files changed, 527 insertions(+), 23 deletions(-) delete mode 100644 awesome_dashboard/static/src/dashboard.js delete mode 100644 awesome_dashboard/static/src/dashboard.xml create mode 100644 awesome_dashboard/static/src/dashboard/dashboard.js create mode 100644 awesome_dashboard/static/src/dashboard/dashboard.xml create mode 100644 awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.js create mode 100644 awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.xml create mode 100644 awesome_dashboard/static/src/dashboard/dashboardSetting/dashboardsetting.js create mode 100644 awesome_dashboard/static/src/dashboard/dashboardSetting/dashboardsetting.xml create mode 100644 awesome_dashboard/static/src/dashboard/dashbord.scss create mode 100644 awesome_dashboard/static/src/dashboard/dashbord_item.js create mode 100644 awesome_dashboard/static/src/dashboard/numbercard/numbercard.js create mode 100644 awesome_dashboard/static/src/dashboard/numbercard/numbercard.xml create mode 100644 awesome_dashboard/static/src/dashboard/piechart/piechart.js create mode 100644 awesome_dashboard/static/src/dashboard/piechart/piechart.xml create mode 100644 awesome_dashboard/static/src/dashboard/piechartcard/piechartcard.js create mode 100644 awesome_dashboard/static/src/dashboard/piechartcard/piechartcard.xml create mode 100644 awesome_dashboard/static/src/dashboard/statistics.js create mode 100644 awesome_dashboard/static/src/dashboard_action.js diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js deleted file mode 100644 index 637fa4bb972..00000000000 --- a/awesome_dashboard/static/src/dashboard.js +++ /dev/null @@ -1,10 +0,0 @@ -/** @odoo-module **/ - -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.xml b/awesome_dashboard/static/src/dashboard.xml deleted file mode 100644 index 1a2ac9a2fed..00000000000 --- a/awesome_dashboard/static/src/dashboard.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - hello dashboard - - - diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..abbaff37165 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,78 @@ +/** @odoo-module **/ + +import { Component, onWillStart, useState } 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 "./dashboardItem/dashboarditem"; +import { rpc } from "@web/core/network/rpc"; +import { DashboardSetting } from "./dashboardSetting/dashboardsetting"; +import { PieChart } from "./piechart/piechart"; + + +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + + static components = { Layout, DashboardItem, PieChart }; + + setup() { + const dashboardItemsRegistry = registry.category("awesome_dashboard"); + this.items = dashboardItemsRegistry.getAll(); + this.dialogService = useService("dialog"); + + + this.action = useService("action"); + this.statisticsService = useService("awesome_dashboard.statistics"); + this.state = useState({ statistics: this.statisticsService.statistics }); + + + this.displayState = useState({ + disabledItems: [], + isLoading: true, + }); + onWillStart(async () => { + try { + const fetchedDisabledItems = await rpc("/web/dataset/call_kw/res.users/get_dashboard_settings", { + model: 'res.users', + method: 'get_dashboard_settings', + args: [], + kwargs: {}, + }); + this.displayState.disabledItems = fetchedDisabledItems; + } catch (error) { + console.error("Error loading initial dashboard settings from server:", error); + this.displayState.disabledItems = []; + } finally { + this.displayState.isLoading = false; + } + }); + } + + updateSettings(newUncheckedItems) { + this.displayState.disabledItems.length = 0; + this.displayState.disabledItems.push(...newUncheckedItems); + } + + openSettings() { + this.dialogService.add(DashboardSetting, { + items: this.items, + initialDisabledItems: this.displayState.disabledItems, + updateSettings: this.updateSettings.bind(this), + }); + } + + openCustomerView() { + this.action.doAction("base.action_partner_form") + } + + openLeadsView() { + this.action.doAction({ + type: 'ir.actions.act_window', + target: 'current', + res_model: 'crm.lead', + views: [[false, 'list'], [false, 'form']], + }) + } +} + +registry.category("lazy_components").add("AwesomeDashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..bbf4b8fa7e0 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + +
+ + + + + + +
+
+
+ +
diff --git a/awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.js b/awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.js new file mode 100644 index 00000000000..15e7c7404bd --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.js @@ -0,0 +1,11 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl" + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem" + + static props = { + size: { type: Number, optional: true, default: 1 }, + } +} diff --git a/awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.xml b/awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.xml new file mode 100644 index 00000000000..cacdc48785b --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.xml @@ -0,0 +1,10 @@ + + + + +
+ +
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboardSetting/dashboardsetting.js b/awesome_dashboard/static/src/dashboard/dashboardSetting/dashboardsetting.js new file mode 100644 index 00000000000..59c75cbd722 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboardSetting/dashboardsetting.js @@ -0,0 +1,58 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; +import { rpc } from "@web/core/network/rpc"; +import { _t } from "@web/core/l10n/translation"; + +export class DashboardSetting extends Component { + static template = "awesome_dashboard.setting"; + + static components = { Dialog }; + + static props = { + close: { type: Function } + }; + + setup() { + const items = this.props.items || {}; + const initialDisabledItems = this.props.initialDisabledItems || []; + this.settingDisplayItems = Object.values(items).map((item) => ({ + ...item, + checked: !initialDisabledItems.includes(item.id), + })) + } + + _t(...args) { + return _t(...args); + } + + onChange(checked, itemInDialog) { + const targetItem = this.settingDisplayItems.find(i => i.id === itemInDialog.id); + if (targetItem) { + targetItem.checked = checked; + } + } + + async confirmDone() { + const newDisableItems = this.settingDisplayItems.filter((item) => !item.checked).map((item) => item.id); + console.log("Items to disable:", newDisableItems); + + try { + const result = await rpc("/web/dataset/call_kw/res.users/set_dashboard_settings", { + model: 'res.users', + method: 'set_dashboard_settings', + args: [newDisableItems], + kwargs: {}, + }); + console.log("RPC call successful, result:", result); + } catch (error) { + console.error("RPC call failed:", error); + } + + if (this.props.updateSettings) { + this.props.updateSettings(newDisableItems); + } + this.props.close(); + } +} diff --git a/awesome_dashboard/static/src/dashboard/dashboardSetting/dashboardsetting.xml b/awesome_dashboard/static/src/dashboard/dashboardSetting/dashboardsetting.xml new file mode 100644 index 00000000000..c415c8d77a1 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboardSetting/dashboardsetting.xml @@ -0,0 +1,26 @@ + + + + +
+

Select items to display on your dashboard:

+
+ + +
+
+ + + + +
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashbord.scss b/awesome_dashboard/static/src/dashboard/dashbord.scss new file mode 100644 index 00000000000..22f0895fd7f --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashbord.scss @@ -0,0 +1,40 @@ +.o_dashboard{ + background-color: gray; +} +.o_dashboard_stat_block { + text-align: center; + margin-bottom: 24px; +} + +.o_dashboard_stat_label { + font-weight: normal; + margin-bottom: 10px; + display: block; +} + +.o_dashboard_stat_value { + font-size: 48px; + color: #228B22; + font-weight: bold; +} +.o_dashboard_item { + background: #fff; + border-radius: 0.75rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.07); + padding: 1rem; + margin: 1rem; + display: inline-flex; + justify-content: center; + vertical-align: top; + min-height: 3rem; +} + +@media (max-width: 426px) { + .o_dashboard_item { + width: 100% !important; + display: flex; + margin-left: 0.5rem; + margin-right: 0.5rem; + box-sizing: border-box; + } +} diff --git a/awesome_dashboard/static/src/dashboard/dashbord_item.js b/awesome_dashboard/static/src/dashboard/dashbord_item.js new file mode 100644 index 00000000000..f93c1720fd2 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashbord_item.js @@ -0,0 +1,70 @@ +import { NumberCard } from "./numbercard/numbercard"; +import { PieChartCard } from "./piechartcard/piechartcard"; +import { registry } from "@web/core/registry"; +import { _t } from "@web/core/l10n/translation"; + +const items = [ + { + id: "nb_new_orders", + description: _t("The number of new orders, this month"), + Component: NumberCard, + size: 1, + props: (data) => ({ + title: _t("New Orders This Month:"), + value: data.data.nb_new_orders + }), + }, + { + id: "total_amount", + description: _t("The total amount of orders, this month"), + Component: NumberCard, + size: 2, + props: (data) => ({ + title: "Total Amount This Month:", + value: data.data.total_amount + }), + }, + { + id: "average_quantity", + description: _t("The average number of t-shirts by order"), + Component: NumberCard, + size: 1, + props: (data) => ({ + title: _t("Avg. T-Shirts per Order:"), + value: data.data.average_quantity + }), + }, + { + id: "nb_cancelled_orders", + description: _t("The number of cancelled orders, this month"), + Component: NumberCard, + size: 1, + props: (data) => ({ + title: _t("Cancelled Orders:"), + value: data.data.nb_cancelled_orders + }), + }, + { + id: "average_time", + description: _t("The average time (in hours) elapsed between the moment an order is created, and the moment is it sent"), + Component: NumberCard, + size: 1, + props: (data) => ({ + title: _t("Avg. Time New → Sent/Cancelled:"), + value: data.data.average_time + }), + }, + { + id: "orders_by_size", + description: _t("Number of shirts ordered based on size"), + Component: PieChartCard, + size: 3, + props: (data) => ({ + title: _t("Shirt orders by size:"), + value: data.data.orders_by_size + }), + } +] +items.forEach((item) => { + registry.category("awesome_dashboard").add(item.id, item) +}); 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..d433197bec3 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/numbercard/numbercard.js @@ -0,0 +1,17 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { _t } from "@web/core/l10n/translation"; + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + + static props = { + title: { type: String }, + value: { type: [String, Number] } + } + + setup() { + this._t = _t; + } +} 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..24fffbbf69f --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/numbercard/numbercard.xml @@ -0,0 +1,16 @@ + + + +
+ + + + + + + + +
+
+ +
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..be91ff81193 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/piechart/piechart.js @@ -0,0 +1,69 @@ +/** @odoo-module **/ + +import { Component, onWillStart, useRef, onMounted, useEffect, onWillUnmount } from "@odoo/owl"; +import { loadJS } from "@web/core/assets" + +export class PieChart extends Component { + static template = "awesome_dashboard.Piechart"; + static props = { + data: { type: Object }, + onSliceClick: { type: Function, optional: true }, + }; + setup() { + this.chart = null; + this.pieChartCanvasRef = useRef("pie_chart_canvas"); + + onWillStart(async () => { + await loadJS("/web/static/lib/Chart/Chart.js"); + }) + + this.chartData = { + labels: Object.keys(this.props.data), + datasets: [{ + data: Object.values(this.props.data) + }] + }; + + onMounted(() => { + this.makePieChart(); + }) + + this.cleanupPieChart = () => { + if (this.chart) { + this.chart.destroy(); + this.chart = null; + } + }; + + onWillUnmount(this.cleanupPieChart); + + + useEffect(() => { + this.cleanupPieChart(); + if (this.pieChartCanvasRef.el) { + this.makePieChart(); + } + }, () => [this.props.data]) + } + + makePieChart() { + this.chart = new Chart(this.pieChartCanvasRef.el, { + type: "pie", + data: this.chartData, + options: { + responsive: true, + maintainAspectRatio: false, + onClick: (event, elements) => { + if (elements.length > 0) { + const clickedElementIndex = elements[0].index; + const label = this.chartData.labels[clickedElementIndex]; + if (this.props.onSliceClick) { + this.props.onSliceClick(label); + } + } + } + + } + }) + } +} diff --git a/awesome_dashboard/static/src/dashboard/piechart/piechart.xml b/awesome_dashboard/static/src/dashboard/piechart/piechart.xml new file mode 100644 index 00000000000..83b790ccf7a --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/piechart/piechart.xml @@ -0,0 +1,6 @@ + + + + + + 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..57dc53a323b --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/piechartcard/piechartcard.js @@ -0,0 +1,24 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { PieChart } from "../piechart/piechart"; +import { useService } from "@web/core/utils/hooks"; +import { _t } from "@web/core/l10n/translation"; + +export class PieChartCard extends Component { + static template = "awesome_dashboard.PieChartCard"; + static components = { PieChart }; + + static props = { + title: { type: String }, + data: { type: Object } + } + + setup() { + this.action = useService("action"); + } + + _t(...args) { + return _t(...args); + } +} 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..0d5e4e2eb34 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/piechartcard/piechartcard.xml @@ -0,0 +1,22 @@ + + + +
+ + + + + + + + + + +
+ +
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/statistics.js b/awesome_dashboard/static/src/dashboard/statistics.js new file mode 100644 index 00000000000..e97c4c37487 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/statistics.js @@ -0,0 +1,38 @@ +/** @odoo-module **/ + +import { rpc } from "@web/core/network/rpc"; +import { registry } from "@web/core/registry"; +import { reactive } from "@odoo/owl"; +import { memoize } from "@web/core/utils/functions"; // Import memoize + +const statisticsService = { + start() { + const statistics = reactive({ data: null, loading: true, error: null }); + + async function _fetchStatistics() { + statistics.loading = true; + statistics.error = null; + try { + const response = await rpc("/awesome_dashboard/statistics"); + statistics.data = response; + return response; + } catch (e) { + statistics.error = e; + throw e; + } finally { + statistics.loading = false; + } + } + + const loadStatistics = memoize(_fetchStatistics); + loadStatistics(); + setInterval(_fetchStatistics, 600000); + + + return { + statistics, + loadStatistics, + }; + }, +}; +registry.category("services").add("awesome_dashboard.statistics", statisticsService); diff --git a/awesome_dashboard/static/src/dashboard_action.js b/awesome_dashboard/static/src/dashboard_action.js new file mode 100644 index 00000000000..6d27b2f12a9 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_action.js @@ -0,0 +1,12 @@ +import { Component, xml } from "@odoo/owl" +import { LazyComponent } from "@web/core/assets"; +import { registry } from "@web/core/registry"; + +export class DashboardComponentLoader extends Component { + static components = { LazyComponent } + static template = xml` + + `; + +} +registry.category("actions").add("awesome_dashboard.dashboard", DashboardComponentLoader); diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml index 5f0e0a0231b..159b54d64cc 100644 --- a/awesome_owl/static/src/card/card.xml +++ b/awesome_owl/static/src/card/card.xml @@ -13,4 +13,4 @@ - \ No newline at end of file + diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index c9f96c2b213..a0284f0ac17 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -12,9 +12,7 @@ export class Playground extends Component { this.state = useState({ sum: 2 }); } - incrementSum(value) { + incrementSum() { this.state.sum += 1; } - content1 = "
some content
" - content2 = markup("
some content
") } diff --git a/awesome_owl/static/src/todo/todolist.xml b/awesome_owl/static/src/todo/todolist.xml index c6172408e5c..2b1a0652421 100644 --- a/awesome_owl/static/src/todo/todolist.xml +++ b/awesome_owl/static/src/todo/todolist.xml @@ -14,7 +14,6 @@
-