From 319d8c74a3260f8db3541889fac84b16f0625bb9 Mon Sep 17 00:00:00 2001 From: Martin Doyen Date: Tue, 17 Jun 2025 16:16:58 +0200 Subject: [PATCH 1/4] [ADD] estate: server framework 101 --- .gitignore | 3 + estate/__init__.py | 1 + estate/__manifest__.py | 16 +++ estate/models/__init__.py | 5 + estate/models/estate_property.py | 97 ++++++++++++++ estate/models/estate_property_offer.py | 71 ++++++++++ estate/models/estate_property_tag.py | 14 ++ estate/models/estate_property_type.py | 25 ++++ estate/models/res_users.py | 12 ++ estate/security/ir.model.access.csv | 5 + estate/views/estate_menus.xml | 13 ++ estate/views/estate_property_offer_views.xml | 43 ++++++ estate/views/estate_property_tag_views.xml | 34 +++++ estate/views/estate_property_type_views.xml | 51 ++++++++ estate/views/estate_property_views.xml | 131 +++++++++++++++++++ estate/views/res_users_views.xml | 15 +++ estate_account/__init__.py | 1 + estate_account/__manifest__.py | 9 ++ estate_account/models/__init__.py | 1 + estate_account/models/estate_property.py | 28 ++++ 20 files changed, 575 insertions(+) create mode 100644 estate/__init__.py create mode 100644 estate/__manifest__.py create mode 100644 estate/models/__init__.py create mode 100644 estate/models/estate_property.py 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/models/res_users.py create mode 100644 estate/security/ir.model.access.csv create mode 100644 estate/views/estate_menus.xml create mode 100644 estate/views/estate_property_offer_views.xml create mode 100644 estate/views/estate_property_tag_views.xml create mode 100644 estate/views/estate_property_type_views.xml create mode 100644 estate/views/estate_property_views.xml create mode 100644 estate/views/res_users_views.xml create mode 100644 estate_account/__init__.py create mode 100644 estate_account/__manifest__.py create mode 100644 estate_account/models/__init__.py create mode 100644 estate_account/models/estate_property.py diff --git a/.gitignore b/.gitignore index b6e47617de1..d7ab9e00691 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# Ruff configuration +ruff.toml 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..37d98fa8b7f --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,16 @@ +{ + 'name': "Real estate", + 'version': '1.0', + 'depends': ['base'], + 'data': [ + 'security/ir.model.access.csv', + 'views/estate_property_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_tag_views.xml', + 'views/res_users_views.xml', + 'views/estate_menus.xml', + ], + 'application': True, + 'license': 'LGPL-3', +} 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..238f1a81bf8 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,97 @@ +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_compare, float_is_zero + + +class Property(models.Model): + _name = 'estate.property' + _description = 'Estate property' + _order = 'id desc' + + name = fields.Char("Name", required=True) + description = fields.Text("Description") + postcode = fields.Char("Postcode") + date_availability = fields.Date("Available From", copy=False, default=lambda x: fields.Date.add(fields.Date.today(), months=3)) + expected_price = fields.Float("Expected Price", required=True) + selling_price = fields.Float("Selling Price", readonly=True, copy=False) + bedrooms = fields.Integer("Bedrooms", default=2) + living_area = fields.Integer("Living Area (sqm)") + facades = fields.Integer("Facades") + garage = fields.Boolean("Garage") + garden = fields.Boolean("Garden") + garden_area = fields.Integer("Garden Area (sqm)") + garden_orientation = fields.Selection( + string="Type", + selection=[('north', "North"), ('south', "South"), ('east', "East"), ('west', "West")], + ) + active = fields.Boolean("Active", default=True) + state = fields.Selection( + string="Status", + selection=[('new', "New"), ('offer_received', "Offer Received"), ('offer_accepted', "Offer Accepted"), ('sold', "Sold"), ('cancelled', "Cancelled")], + default='new', + required=True, + copy=False, + ) + property_type_id = fields.Many2one('estate.property.type', string="Property Type") + user_id = fields.Many2one('res.users', string="Salesman", default=lambda self: self.env.user) + partner_id = fields.Many2one('res.partner', string="Buyer", copy=False) + property_tag_ids = fields.Many2many('estate.property.tag') + property_offer_ids = fields.One2many('estate.property.offer', 'property_id') + total_area = fields.Float(compute='_compute_total_area') + best_offer = fields.Float(compute="_compute_best_offer") + + _sql_constraints = [ + ('positive_expected_price', 'CHECK(expected_price > 0)', "A property expected price must be strictly positive"), + ('positive_selling_price', 'CHECK(selling_price >= 0)', "A property selling price must be 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('property_offer_ids.price') + def _compute_best_offer(self): + for record in self: + if not record.property_offer_ids: + record.best_offer = 0 + else: + record.best_offer = max(record.property_offer_ids.mapped('price')) + + @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_set_sold(self): + for record in self: + if self.state == 'cancelled': + raise UserError(_("Property is cancelled and cannot be sold.")) + + record.state = 'sold' + + return True + + def action_set_cancelled(self): + for record in self: + if self.state == 'sold': + raise UserError(_("Sold properties cannot be cancelled.")) + + record.state = 'cancelled' + record.active = False + + return True + + @api.constrains('selling_price') + def _check_selling_price(self): + for record in self: + min_selling_price = record.expected_price * 0.9 + + if not float_is_zero(record.selling_price, precision_digits=2) and float_compare(record.selling_price, min_selling_price, precision_digits=2) < 0: + raise UserError(_( + "The selling price must be at least 90 percent of the expected price! You must reduce the expected price if you want to accept this offer." + )) diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..d9c686688f3 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,71 @@ +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class PropertyOffer(models.Model): + _name = 'estate.property.offer' + _description = 'An offer made for the property' + _order = 'price desc' + + price = fields.Float("Price") + status = fields.Selection( + string="Status", + selection=[('accepted', "Accepted"), ('refused', "Refused")], + copy=False, + ) + partner_id = fields.Many2one('res.partner', required=True) + property_id = fields.Many2one('estate.property', required=True) + validity = fields.Integer("Validity (days)", default=7) + date_deadline = fields.Date("Deadline", compute='_compute_deadline', inverse='_inverse_deadline') + property_type_id = fields.Many2one(related='property_id.property_type_id') + + _sql_constraints = [ + ('positive_offer_price', 'CHECK(price > 0)', "An offer price must be strictly positive"), + ] + + @api.depends('validity', 'create_date') + def _compute_deadline(self): + for record in self: + if not record.validity: + record.date_deadline = False + else: + record.date_deadline = fields.Date.add(fields.Date.today(), days=record.validity) + + def _inverse_deadline(self): + for record in self: + if not record.date_deadline: + record.validity = 0 + else: + record.validity = (record.date_deadline - fields.Date.today()).days + + def action_confirm(self): + for record in self: + record.status = 'accepted' + record.property_id.selling_price = record.price + record.property_id.partner_id = record.partner_id + record.property_id.state = 'offer_accepted' + + for offer in record.property_id.property_offer_ids: + if offer.id != record.id: + offer.action_refuse() + + return True + + def action_refuse(self): + for record in self: + record.status = 'refused' + + return True + + @api.model_create_multi + def create(self, vals): + for val in vals: + val_property_id = val.get('property_id') + property = self.env['estate.property'].browse(val_property_id) + + if property.best_offer >= val.get('price'): + raise UserError(_("A new offer price needs to be greater than current best offer.")) + + property.state = 'offer_received' + + return super().create(vals) diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..d82061243d4 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,14 @@ +from odoo import fields, models + + +class PropertyTag(models.Model): + _name = 'estate.property.tag' + _description = 'Tag of properties' + _order = 'name asc' + + name = fields.Char("Name", required=True) + color = fields.Integer("Color") + + _sql_constraints = [( + 'unique_name', 'unique(name)', "A property 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..83170d16b3a --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,25 @@ +from odoo import api, fields, models + + +class PropertyType(models.Model): + _name = 'estate.property.type' + _description = 'Type of property' + _order = 'name asc' + + name = fields.Char("Name", required=True) + property_ids = fields.One2many('estate.property', 'property_type_id') + sequence = fields.Integer("Sequence") + offer_ids = fields.One2many('estate.property.offer', 'property_type_id') + offer_count = fields.Integer("Count", compute='_compute_offer_count') + + _sql_constraints = [ + ('unique_name', 'unique(name)', "A property type name must be unique"), + ] + + @api.depends('offer_ids') + def _compute_offer_count(self): + for record in self: + if record.offer_ids: + record.offer_count = len(record.offer_ids) + else: + record.offer_count = 0 diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..2140b38c8ec --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = 'res.users' + + property_ids = fields.One2many( + 'estate.property', + 'user_id', + string="Properties", + domain="[('state', 'in', ['new', 'offer_received'])]", + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..e20ec4dd90b --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property,access.estate.property,model_estate_property,base.group_user,1,1,1,1 +access_estate_property_type,access.estate.property.type,model_estate_property_type,base.group_user,1,1,1,1 +access_estate_property_tag,access.estate.property.tag,model_estate_property_tag,base.group_user,1,1,1,1 +access_estate_property_offer,access.estate.property.offer,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 new file mode 100644 index 00000000000..9471f8c00c0 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..05be5c69de5 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,43 @@ + + + + estate.property.offer.list + estate.property.offer + + + + + + + + + +

+ +

+
+ + + + + + + + + + + + + +
+
+ + + Property Types + estate.property.type + list,form + +
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..29fc2a4a286 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,131 @@ + + + + estate.property.view.search + estate.property + + + + + + + + + + + + + + + + estate.property.list + estate.property + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+
+
+ + +

+ +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + estate.property.view.kanban + estate.property + + + + + + + +
+ Expected Price: + +
+
+ Best Offer: + +
+
+ Selling Price: + +
+
+
+
+
+
+ + + Properties + estate.property + list,form,kanban + {'search_default_available': True} + +
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..827687039cf --- /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..ade978fcac5 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,9 @@ +{ + 'name': "Estate Accounting", + 'version': '1.0', + 'depends': ['base', 'account', 'estate'], + 'data': [ + ], + 'application': True, + 'license': 'LGPL-3', +} 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..08c40ee9b06 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,28 @@ +from odoo import Command, models + + +class EstateProperty(models.Model): + _inherit = 'estate.property' + + def action_set_sold(self): + result = super().action_set_sold() + + for record in self: + self.env['account.move'].create({ + 'partner_id': record.partner_id.id, + 'move_type': 'out_invoice', + 'invoice_line_ids': [ + Command.create({ + 'name': f"Down payment for {record.name}", + 'quantity': 1, + 'price_unit': record.selling_price * 0.06, + }), + Command.create({ + 'name': "Administrative fees", + 'quantity': 1, + 'price_unit': 100, + }), + ], + }) + + return result From b9042e8e7f2066cb707aba4202babe656a291c2e Mon Sep 17 00:00:00 2001 From: Martin Doyen Date: Tue, 24 Jun 2025 09:11:52 +0200 Subject: [PATCH 2/4] [IMP] awesome_owl: discover owl components --- awesome_owl/static/src/card/card.js | 19 +++++++++++++ awesome_owl/static/src/card/card.xml | 17 ++++++++++++ awesome_owl/static/src/counter/counter.js | 21 +++++++++++++++ awesome_owl/static/src/counter/counter.xml | 10 +++++++ awesome_owl/static/src/playground.js | 16 ++++++++++- awesome_owl/static/src/playground.xml | 15 ++++++++++- awesome_owl/static/src/todo/todo.js | 14 ++++++++++ awesome_owl/static/src/todo/todo_item.js | 22 +++++++++++++++ awesome_owl/static/src/todo/todo_item.xml | 13 +++++++++ awesome_owl/static/src/todo/todo_list.js | 31 ++++++++++++++++++++++ awesome_owl/static/src/todo/todo_list.xml | 12 +++++++++ awesome_owl/static/src/utils.js | 8 ++++++ awesome_owl/views/templates.xml | 1 + 13 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 awesome_owl/static/src/card/card.js create mode 100644 awesome_owl/static/src/card/card.xml create mode 100644 awesome_owl/static/src/counter/counter.js create mode 100644 awesome_owl/static/src/counter/counter.xml create mode 100644 awesome_owl/static/src/todo/todo.js create mode 100644 awesome_owl/static/src/todo/todo_item.js create mode 100644 awesome_owl/static/src/todo/todo_item.xml create mode 100644 awesome_owl/static/src/todo/todo_list.js create mode 100644 awesome_owl/static/src/todo/todo_list.xml create mode 100644 awesome_owl/static/src/utils.js diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..e2f53d179be --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,19 @@ +/** @odoo-module **/ + +import { Component, useState } from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.card"; + static props = { + title: String, + slots: Object, + }; + + setup() { + this.state = useState({ isOpen: true }); + } + + toggle() { + this.state.isOpen = !this.state.isOpen; + } +} diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 00000000000..eaa30d952f0 --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,17 @@ + + + + +
+
+
+ + +
+

+ +

+
+
+
+
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..bcf0f1b7944 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,21 @@ +/** @odoo-module **/ + +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:0 }); + } + + increment() { + this.state.value++; + if (this.props.onChange) { + 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..a6500fd5b6f --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,10 @@ + + + + +
+ Counter: + +
+
+
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 657fb8b07bb..c564a4496c8 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,7 +1,21 @@ /** @odoo-module **/ -import { Component } from "@odoo/owl"; +import { Component, markup, useState } from "@odoo/owl"; +import { Counter } from "./counter/counter"; +import { Card } from "./card/card"; +import { TodoList } from "./todo/todo_list"; export class Playground extends Component { static template = "awesome_owl.playground"; + static components = { Card, Counter, TodoList }; + + setup() { + this.content1 = "
some content
"; + this.content2 = markup("
some content
"); + this.sum = useState({ value: 2 }); + } + + incrementSum() { + this.sum.value++; + } } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..3094c62f0b3 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -4,7 +4,20 @@
hello world + + +
The sum is:
+
+
+ + text 1 + + + + +
+
+
- diff --git a/awesome_owl/static/src/todo/todo.js b/awesome_owl/static/src/todo/todo.js new file mode 100644 index 00000000000..1b428da22cb --- /dev/null +++ b/awesome_owl/static/src/todo/todo.js @@ -0,0 +1,14 @@ +export class Todo { + static nextId = 1; + + constructor(description) { + this.id = Todo.nextId; + Todo.nextId++; + this.description = description; + this.isCompleted = false; + } + + toggle() { + this.isCompleted = !this.isCompleted; + } +} diff --git a/awesome_owl/static/src/todo/todo_item.js b/awesome_owl/static/src/todo/todo_item.js new file mode 100644 index 00000000000..760e124e129 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.js @@ -0,0 +1,22 @@ +/** @odoo-module **/ + +import { Component, useState } 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, + }}, + removeTodo: { + type: Function, + }, + }; + + remove() { + this.props.removeTodo(this.props.todo.id); + } +} diff --git a/awesome_owl/static/src/todo/todo_item.xml b/awesome_owl/static/src/todo/todo_item.xml new file mode 100644 index 00000000000..8192a064464 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.xml @@ -0,0 +1,13 @@ + + + + +
+ + + +
+
+
diff --git a/awesome_owl/static/src/todo/todo_list.js b/awesome_owl/static/src/todo/todo_list.js new file mode 100644 index 00000000000..cb4ff03f992 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.js @@ -0,0 +1,31 @@ +/** @odoo-module **/ + +import { Component, useState, useRef, onMounted } from "@odoo/owl"; +import { TodoItem } from "./todo_item"; +import { Todo } from "./todo"; +import { useAutoFocus } from "../utils"; + +export class TodoList extends Component { + static template = "awesome_owl.todolist"; + static components = { TodoItem }; + + setup() { + this.todos = useState([]); + useAutoFocus("input"); + } + + addTodo(ev) { + if (ev.keyCode === 13 && ev.target.value !== '') { + this.todos.push(new Todo(ev.target.value)) + ev.target.value=''; + } + } + + removeTodo(id) { + const index = this.todos.findIndex((elem) => elem.id === id); + + if (index >= 0) { + this.todos.splice(index, 1); + } + } +} diff --git a/awesome_owl/static/src/todo/todo_list.xml b/awesome_owl/static/src/todo/todo_list.xml new file mode 100644 index 00000000000..f2598a83dd7 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.xml @@ -0,0 +1,12 @@ + + + + +
+ + + + +
+
+
diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..c68a4a5028e --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,8 @@ +import { useRef, onMounted } from "@odoo/owl" + +export function useAutoFocus(refName) { + const ref = useRef(refName) + onMounted(() => { + ref.el.focus() + }) +} diff --git a/awesome_owl/views/templates.xml b/awesome_owl/views/templates.xml index aa54c1a7241..3df6b44bd5b 100644 --- a/awesome_owl/views/templates.xml +++ b/awesome_owl/views/templates.xml @@ -5,6 +5,7 @@ + From b32f7d88d33d2940b738be6313fb3c8aa86e1e6a Mon Sep 17 00:00:00 2001 From: Martin Doyen Date: Wed, 25 Jun 2025 17:03:54 +0200 Subject: [PATCH 3/4] [IMP] awesome_dashboard: build a dashboard --- awesome_dashboard/__manifest__.py | 8 +- awesome_dashboard/static/src/dashboard.js | 10 --- awesome_dashboard/static/src/dashboard.xml | 8 -- .../static/src/dashboard/dashboard.js | 90 +++++++++++++++++++ .../static/src/dashboard/dashboard.scss | 3 + .../static/src/dashboard/dashboard.xml | 41 +++++++++ .../static/src/dashboard/dashboard_item.js | 20 +++++ .../static/src/dashboard/dashboard_item.xml | 12 +++ .../static/src/dashboard/dashboard_items.js | 67 ++++++++++++++ .../src/dashboard/number_card/number_card.js | 13 +++ .../src/dashboard/number_card/number_card.xml | 9 ++ .../src/dashboard/pie_chart/pie_chart.js | 64 +++++++++++++ .../src/dashboard/pie_chart/pie_chart.xml | 6 ++ .../pie_chart_card/pie_chart_card.js | 15 ++++ .../pie_chart_card/pie_chart_card.xml | 7 ++ .../src/dashboard/statistics_service.js | 21 +++++ .../static/src/dashboard_loader.js | 14 +++ 17 files changed, 388 insertions(+), 20 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.scss create mode 100644 awesome_dashboard/static/src/dashboard/dashboard.xml create mode 100644 awesome_dashboard/static/src/dashboard/dashboard_item.js create mode 100644 awesome_dashboard/static/src/dashboard/dashboard_item.xml create mode 100644 awesome_dashboard/static/src/dashboard/dashboard_items.js create mode 100644 awesome_dashboard/static/src/dashboard/number_card/number_card.js create mode 100644 awesome_dashboard/static/src/dashboard/number_card/number_card.xml create mode 100644 awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js create mode 100644 awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml create mode 100644 awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js create mode 100644 awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml create mode 100644 awesome_dashboard/static/src/dashboard/statistics_service.js create mode 100644 awesome_dashboard/static/src/dashboard_loader.js diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index 31406e8addb..e9c0c7558b2 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -16,7 +16,7 @@ 'version': '0.1', 'application': True, 'installable': True, - 'depends': ['base', 'web', 'mail', 'crm'], + 'depends': ['base', 'web', 'mail', 'crm', 'sale'], 'data': [ 'views/views.xml', @@ -24,7 +24,11 @@ 'assets': { 'web.assets_backend': [ 'awesome_dashboard/static/src/**/*', + ('remove', 'awesome_dashboard/static/src/dashboard/**/*'), + ], + 'awesome_dashboard.dashboard': [ + 'awesome_dashboard/static/src/dashboard/**/*', ], }, - 'license': 'AGPL-3' + 'license': 'AGPL-3', } 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..e7d8c7f633a --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,90 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { Component, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { Layout } from "@web/search/layout"; +import { PieChart } from "./pie_chart/pie_chart"; +import { DashboardItem } from "./dashboard_item"; +import { useService } from "@web/core/utils/hooks"; +import { Dialog } from "@web/core/dialog/dialog"; +import { CheckBox } from "@web/core/checkbox/checkbox"; +import { browser } from "@web/core/browser/browser"; + +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { DashboardItem, Layout, PieChart }; + + setup() { + this.action = useService("action"); + this.stats = useState(useService("awesome_dashboard.statistics")); + this.items = registry.category("awesome_dashboard").getAll(); + this.dialog = useService("dialog"); + this.state = useState({ + disabledItems: browser.localStorage.getItem("disabledDashboardItems")?.split(",") || [] + }) + } + + openCustomers() { + this.action.doAction("base.action_partner_form"); + } + + openLeads() { + this.action.doAction({ + type: 'ir.actions.act_window', + name: _t('All leads'), + res_model: 'crm.lead', + views: [ + [false, 'list'], + [false, 'form'], + ], + }); + } + + openConfiguration() { + this.dialog.add(ConfigurationDialog, { + items: this.items, + disabledItems: this.state.disabledItems, + onUpdateConfiguration: this.updateConfiguration.bind(this), + }) + } + + updateConfiguration(newDisabledItems) { + this.state.disabledItems = newDisabledItems; + } +} + +class ConfigurationDialog extends Component { + static template = "awesome_dashboard.ConfigurationDialog"; + static components = { Dialog, CheckBox }; + static props = ["close", "items", "disabledItems", "onUpdateConfiguration"]; + + setup() { + this.items = useState(this.props.items.map((item) => { + return { + ...item, + enabled: !this.props.disabledItems.includes(item.id), + } + })); + } + + done() { + this.props.close(); + } + + onChange(checked, changedItem) { + changedItem.enabled = checked; + const newDisabledItems = Object.values(this.items).filter( + (item) => !item.enabled + ).map((item) => item.id) + + browser.localStorage.setItem( + "disabledDashboardItems", + newDisabledItems + ); + + this.props.onUpdateConfiguration(newDisabledItems); + } +} + +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..32862ec0d82 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.scss @@ -0,0 +1,3 @@ +.o_dashboard { + background-color: gray; +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..e50eb5e77d0 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + +
+ + + + + + +
+
+
+ + + + 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..58c2fc06fab --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item.js @@ -0,0 +1,20 @@ +import { Component } from "@odoo/owl"; + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem"; + static props = { + slots: { + type: Object, + }, + size: { + type: Number, + optional: true, + }, + }; + + setup() { + if (!this.props.size) { + this.props.size = 1; + } + } +} 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..76a58cfa722 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item.xml @@ -0,0 +1,12 @@ + + + +
+
+
+ +
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_items.js b/awesome_dashboard/static/src/dashboard/dashboard_items.js new file mode 100644 index 00000000000..64a442de30e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,67 @@ +import { NumberCard } from "./number_card/number_card"; +import { PieChartCard } from "./pie_chart_card/pie_chart_card"; +import { registry } from "@web/core/registry"; +import { _t } from "@web/core/l10n/translation"; + +const items = [ + { + id: "average_quantity", + description: _t("Average amount of t-shirt"), + Component: NumberCard, + props: (data) => ({ + title: _t("Average amount of t-shirt by order this month"), + value: data.average_quantity, + }), + }, + { + id: "average_time", + description: _t("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, + }), + }, + { + id: "number_new_orders", + description: _t("New orders this month"), + Component: NumberCard, + props: (data) => ({ + title: _t("Number of new orders this month"), + value: data.nb_new_orders, + }), + }, + { + id: "nb_cancelled_orders", + description: _t("Cancelled orders this month"), + Component: NumberCard, + props: (data) => ({ + title: _t("Number of cancelled orders this month"), + value: data.nb_cancelled_orders, + }), + }, + { + id: "total_amount", + description: _t("Amount of orders this month"), + Component: NumberCard, + props: (data) => ({ + title: _t("Total amount of new orders this month"), + value: data.total_amount, + }), + }, + { + id: "pie_chart", + description: _t("Shirt orders by size"), + Component: PieChartCard, + size: 2, + props: (data) => ({ + title: _t("Shirt orders by size"), + data: data.orders_by_size, + }), + }, +] + +items.forEach(item => { + registry.category("awesome_dashboard").add(item.id, item); +}) diff --git a/awesome_dashboard/static/src/dashboard/number_card/number_card.js b/awesome_dashboard/static/src/dashboard/number_card/number_card.js new file mode 100644 index 00000000000..36706b348d2 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.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/number_card/number_card.xml b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml new file mode 100644 index 00000000000..3b67b1a3a1c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml @@ -0,0 +1,9 @@ + + + + +
+ +
+
+
\ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js new file mode 100644 index 00000000000..845c9fd780f --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js @@ -0,0 +1,64 @@ +import { Component, useRef, onMounted, onWillStart, onWillUnmount, onWillUpdateProps } from "@odoo/owl"; +import { loadJS } from "@web/core/assets"; +import { useService } from "@web/core/utils/hooks"; +import { _t } from "@web/core/l10n/translation"; + +export class PieChart extends Component { + static template = "awesome_dashboard.PieChart"; + static props = { + 'data': { + type: Object, + }, + }; + + setup() { + onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js")); + this.chartRef = useRef("chart"); + onMounted(() => { + this.renderChart(); + }); + onWillUpdateProps((props) => { + this.chart.data.datasets[0].data = Object.values(props.data); + this.chart.update(); + }); + onWillUnmount(() => { + this.chart.destroy(); + }); + this.action = useService("action"); + } + + renderChart() { + const labels = Object.keys(this.props.data); + this.chart = new Chart(this.chartRef.el, { + type: "pie", + data: { + labels: labels, + datasets: [{ + data: Object.values(this.props.data), + }], + }, + options: { + onClick: (ev, elements) => { + if (elements.length > 0) { + const index = elements[0].index; + const clickedLabel = labels[index]; + this.openOrderListView(clickedLabel) + } + } + } + }); + }; + + openOrderListView(size) { + this.action.doAction({ + type: "ir.actions.act_window", + name: _t(`Orders with size ${size}`), + res_model: "sale.order", + views: [ + [false, 'list'], + [false, 'form'], + ], + domain: [["name", "=", size]], + }); + } +} diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml new file mode 100644 index 00000000000..f0ccbab4d94 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js new file mode 100644 index 00000000000..495ed14cd85 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js @@ -0,0 +1,15 @@ +import { Component } from "@odoo/owl"; +import { PieChart } from "../pie_chart/pie_chart"; + +export class PieChartCard extends Component { + static template = "awesome_dashboard.PieChartCard"; + static components = { PieChart }; + static props = { + title: { + type: String, + }, + data: { + type: Object, + } + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml new file mode 100644 index 00000000000..dd055b535c0 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file 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..3b9afc12213 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/statistics_service.js @@ -0,0 +1,21 @@ +import { registry } from "@web/core/registry"; +import { reactive } from "@odoo/owl"; +import { rpc } from "@web/core/network/rpc"; + +export const loadStatistics = { + start() { + const data = reactive({ isReady: false }) + + async function loadData() { + const update = await rpc("/awesome_dashboard/statistics"); + Object.assign(data, update, { isReady: true }) + } + + setInterval(loadData, 10*60*1000); + loadData(); + + return data + }, +}; + +registry.category("services").add("awesome_dashboard.statistics", loadStatistics); \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard_loader.js b/awesome_dashboard/static/src/dashboard_loader.js new file mode 100644 index 00000000000..6403253b181 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_loader.js @@ -0,0 +1,14 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { Component, xml } from "@odoo/owl"; +import { LazyComponent } from "@web/core/assets"; + +export class DashboardLoader extends Component { + static components = { LazyComponent }; + static template = xml` + + `; +} + +registry.category("actions").add("awesome_dashboard.dashboard", DashboardLoader); From 902ccaa607ca01640962446e23b38b9b14d5ea6f Mon Sep 17 00:00:00 2001 From: Martin Doyen Date: Fri, 27 Jun 2025 11:18:01 +0200 Subject: [PATCH 4/4] [IMP] awesome_clicker: Add BigBots Chapter 11 of tutorial "Master the web framework" > "Chapter 1: Build a Clicker Game" --- .../clicker_action/clicker_client_action.js | 15 +++++ .../clicker_action/clicker_client_action.xml | 35 +++++++++++ awesome_clicker/static/src/clicker_hook.js | 6 ++ awesome_clicker/static/src/clicker_service.js | 27 ++++++++ .../clicker_systray_item.js | 30 +++++++++ .../clicker_systray_item.xml | 16 +++++ .../static/src/clicker_value/clicker_value.js | 17 ++++++ .../src/clicker_value/clicker_value.xml | 6 ++ .../static/src/model/clicker_model.js | 61 +++++++++++++++++++ 9 files changed, 213 insertions(+) create mode 100644 awesome_clicker/static/src/clicker_action/clicker_client_action.js create mode 100644 awesome_clicker/static/src/clicker_action/clicker_client_action.xml create mode 100644 awesome_clicker/static/src/clicker_hook.js create mode 100644 awesome_clicker/static/src/clicker_service.js create mode 100644 awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js create mode 100644 awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml create mode 100644 awesome_clicker/static/src/clicker_value/clicker_value.js create mode 100644 awesome_clicker/static/src/clicker_value/clicker_value.xml create mode 100644 awesome_clicker/static/src/model/clicker_model.js diff --git a/awesome_clicker/static/src/clicker_action/clicker_client_action.js b/awesome_clicker/static/src/clicker_action/clicker_client_action.js new file mode 100644 index 00000000000..42c8f3b52b0 --- /dev/null +++ b/awesome_clicker/static/src/clicker_action/clicker_client_action.js @@ -0,0 +1,15 @@ +import { Component, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useClicker } from "../clicker_hook"; +import { ClickerValue } from "../clicker_value/clicker_value"; + +class ClickerClientAction extends Component { + static template = "awesome_clicker.ClickerClientAction"; + static components = { ClickerValue }; + + setup() { + this.clickerService = useClicker(); + } +} + +registry.category("actions").add("awesome_clicker.clicker_client_action", ClickerClientAction); diff --git a/awesome_clicker/static/src/clicker_action/clicker_client_action.xml b/awesome_clicker/static/src/clicker_action/clicker_client_action.xml new file mode 100644 index 00000000000..401001672bf --- /dev/null +++ b/awesome_clicker/static/src/clicker_action/clicker_client_action.xml @@ -0,0 +1,35 @@ + + + + + Clicks: + + +

Bots

+
+
+
+
+ x ClickBots (10 clicks/10 seconds) + +
+
+
+ +
+
+ +
+
+
+ x bigBots (100 clicks/10 seconds) + +
+
+
+ +
+
+
+
+
diff --git a/awesome_clicker/static/src/clicker_hook.js b/awesome_clicker/static/src/clicker_hook.js new file mode 100644 index 00000000000..64ec02c382a --- /dev/null +++ b/awesome_clicker/static/src/clicker_hook.js @@ -0,0 +1,6 @@ +import { useState } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; + +export function useClicker() { + return useState(useService("awesome_clicker.count")); +} diff --git a/awesome_clicker/static/src/clicker_service.js b/awesome_clicker/static/src/clicker_service.js new file mode 100644 index 00000000000..912c4edb122 --- /dev/null +++ b/awesome_clicker/static/src/clicker_service.js @@ -0,0 +1,27 @@ +import { registry } from "@web/core/registry"; +import { ClickerModel } from "./model/clicker_model"; + +export const getCount = { + dependencies: ["effect"], + start(env, services) { + const clicker = new ClickerModel(); + + setInterval(() => { + clicker.tick(); + }, 10000) + + const bus = clicker.bus + bus.addEventListener("MILESTONE", (ev) => { + services.effect.add({ + message: `Milestone reached! You can now buy ${ev.detail}!`, + type: "rainbow_man", + }) + }) + + document.addEventListener("click", () => clicker.increment(1), true) + + return clicker; + } +} + +registry.category("services").add("awesome_clicker.count", getCount); diff --git a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js new file mode 100644 index 00000000000..8f92c121600 --- /dev/null +++ b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js @@ -0,0 +1,30 @@ +import { registry } from "@web/core/registry"; +import { Component } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { useClicker } from "../clicker_hook"; +import { ClickerValue } from "../clicker_value/clicker_value"; + +export class ClickerSystray extends Component { + static template = "awesome_clicker.ClickerSystray"; + static components = { ClickerValue }; + + setup() { + this.counterService = useClicker(); + this.action = useService("action"); + } + + openClientAction() { + this.action.doAction({ + type: "ir.actions.client", + tag: "awesome_clicker.clicker_client_action", + target: "new", + name: "Clicker Game", + }) + } +} + +export const systrayItem = { + Component: ClickerSystray, +}; + +registry.category("systray").add("awesome_clicker.ClickerSystray", systrayItem, { sequence: 1000 }); diff --git a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml new file mode 100644 index 00000000000..591f368ba48 --- /dev/null +++ b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml @@ -0,0 +1,16 @@ + + + +
+
+ Clicks: +
+ + +
+
+
\ No newline at end of file diff --git a/awesome_clicker/static/src/clicker_value/clicker_value.js b/awesome_clicker/static/src/clicker_value/clicker_value.js new file mode 100644 index 00000000000..78ff51154e9 --- /dev/null +++ b/awesome_clicker/static/src/clicker_value/clicker_value.js @@ -0,0 +1,17 @@ +import { Component } from "@odoo/owl"; +import { useClicker } from "../clicker_hook"; +import { humanNumber } from "@web/core/utils/numbers"; + +export class ClickerValue extends Component { + static template = "awesome_clicker.ClickerValue"; + + setup() { + this.clicker = useClicker(); + } + + get getClicks() { + return humanNumber(this.clicker.clicks, { + decimals: 1, + }); + } +} diff --git a/awesome_clicker/static/src/clicker_value/clicker_value.xml b/awesome_clicker/static/src/clicker_value/clicker_value.xml new file mode 100644 index 00000000000..92a65c39c0f --- /dev/null +++ b/awesome_clicker/static/src/clicker_value/clicker_value.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/awesome_clicker/static/src/model/clicker_model.js b/awesome_clicker/static/src/model/clicker_model.js new file mode 100644 index 00000000000..05ae006a6e5 --- /dev/null +++ b/awesome_clicker/static/src/model/clicker_model.js @@ -0,0 +1,61 @@ +import { Reactive } from "@web/core/utils/reactive"; +import { EventBus } from "@odoo/owl"; + +export class ClickerModel extends Reactive { + + constructor() { + super(); + this.clicks = 0; + this.level = 0; + this.bus = new EventBus(); + this.bots = { + clickBot: { + price: 1000, + level: 1, + increment: 10, + purchased: 0, + }, + bigBot: { + price: 5000, + level: 2, + increment: 100, + purchased: 0, + }, + } + } + + increment(value) { + this.clicks += value; + + if (this.milestones[this.level] && this.clicks >= this.milestones[this.level].clicks) { + this.bus.trigger("MILESTONE", this.milestones[this.level].unlock); + this.level++; + } + } + + buyBot(name) { + if (!Object.keys(this.bots).includes(name)) { + throw new Error(`Invalid bot name ${name}`) + } + + if (this.clicks < this.bots[name].price) { + return false; + } + + this.clicks -= this.bots[name].price; + this.bots[name].purchased += 1; + } + + tick() { + for (const bot in this.bots) { + this.increment(this.bots[bot].purchased * this.bots[bot].increment); + } + } + + get milestones() { + return [ + { clicks: 1000, unlock: "clickbots" }, + { clicks: 5000, unlock: "bigbots" }, + ]; + } +}