diff --git a/awesome_clicker/static/src/clicker_systray/clicker_systray_item.js b/awesome_clicker/static/src/clicker_systray/clicker_systray_item.js
new file mode 100644
index 00000000000..3fa55915374
--- /dev/null
+++ b/awesome_clicker/static/src/clicker_systray/clicker_systray_item.js
@@ -0,0 +1,53 @@
+import { Component, useExternalListener } from "@odoo/owl";
+import { registry } from "@web/core/registry";
+import { useService } from "@web/core/utils/hooks";
+import { useClicker } from "../utility";
+import { ClickerValue } from "../clicker_value/clicker_value";
+import { DropdownItem } from "@web/core/dropdown/dropdown_item";
+import { Dropdown } from "@web/core/dropdown/dropdown"
+import { treeTypes } from "../models/clicker_model";
+
+export class ClickerSystrayItem extends Component {
+ static template = "awesome_clicker.ClickerSystrayItem";
+
+ static components = {
+ ClickerValue,
+ Dropdown,
+ DropdownItem
+ }
+
+ setup() {
+ this.clicker = useClicker();
+ this.actionService = useService("action");
+
+ useExternalListener(document, "click", this.increment, {capture: true})
+ }
+
+ bigIncrement() {
+ this.clicker.increment(10);
+ }
+
+ increment(ev) {
+ if(ev.target.closest("#clicker-big-increment-button")) return;
+
+ this.clicker.increment(1);
+ }
+
+ openClientAction() {
+ this.actionService.doAction({
+ type: 'ir.actions.client',
+ tag: 'awesome_clicker.client_action',
+ target: 'new',
+ name: 'Clicker'
+ })
+ }
+
+ getTreeTypes() {
+ return treeTypes;
+ }
+}
+
+registry.category("systray").add("awesome_clicker.client_action", {
+ Component: ClickerSystrayItem,
+ sequence: 1
+});
diff --git a/awesome_clicker/static/src/clicker_systray/clicker_systray_item.scss b/awesome_clicker/static/src/clicker_systray/clicker_systray_item.scss
new file mode 100644
index 00000000000..a46b17cd8fe
--- /dev/null
+++ b/awesome_clicker/static/src/clicker_systray/clicker_systray_item.scss
@@ -0,0 +1,3 @@
+.clicker-systray-separator {
+ margin: 0 0.2rem
+}
diff --git a/awesome_clicker/static/src/clicker_systray/clicker_systray_item.xml b/awesome_clicker/static/src/clicker_systray/clicker_systray_item.xml
new file mode 100644
index 00000000000..10f8a971ea4
--- /dev/null
+++ b/awesome_clicker/static/src/clicker_systray/clicker_systray_item.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+ ,
+
+
+ ,
+
+
+
+
+
+
+ Open the clicker game
+
+
+ Buy a Clickbot
+
+
+
+
+
+
+
+
+
+
+
+
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..9b4d0084461
--- /dev/null
+++ b/awesome_clicker/static/src/clicker_value/clicker_value.js
@@ -0,0 +1,15 @@
+import { Component } from "@odoo/owl";
+import { humanNumber } from "@web/core/utils/numbers"
+
+export class ClickerValue extends Component {
+ static template = "awesome_clicker.ClientValue";
+
+ static props = {
+ name: String,
+ value: Number
+ }
+
+ getValue() {
+ return humanNumber(this.props.value)
+ }
+}
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..fd6f241acee
--- /dev/null
+++ b/awesome_clicker/static/src/clicker_value/clicker_value.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/awesome_clicker/static/src/client_action/client_action.js b/awesome_clicker/static/src/client_action/client_action.js
new file mode 100644
index 00000000000..e129f3e675e
--- /dev/null
+++ b/awesome_clicker/static/src/client_action/client_action.js
@@ -0,0 +1,46 @@
+import { Component } from "@odoo/owl";
+import { registry } from "@web/core/registry";
+import { useClicker } from "../utility";
+import { ClickerValue } from "../clicker_value/clicker_value";
+import { treeTypes } from "../models/clicker_model";
+import { Notebook } from "@web/core/notebook/notebook";
+
+
+export class ClientAction extends Component {
+ static template = "awesome_clicker.ClientAction";
+
+ static components = {
+ ClickerValue,
+ Notebook
+ }
+
+ setup() {
+ this.clicker = useClicker();
+ }
+
+ bigIncrement() {
+ this.clicker.increment(100_000);
+ }
+
+ buyClickBot() {
+ this.clicker.buyClickBot();
+ }
+
+ buyBigClickBot() {
+ this.clicker.buyBigClickBot();
+ }
+
+ buyPower() {
+ this.clicker.buyPower();
+ }
+
+ buyTree(fruitName) {
+ this.clicker.buyTree(fruitName);
+ }
+
+ getTreeTypes() {
+ return treeTypes;
+ }
+}
+
+registry.category("actions").add("awesome_clicker.client_action", ClientAction)
diff --git a/awesome_clicker/static/src/client_action/client_action.xml b/awesome_clicker/static/src/client_action/client_action.xml
new file mode 100644
index 00000000000..760a10b09c8
--- /dev/null
+++ b/awesome_clicker/static/src/client_action/client_action.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+ Bots
+
+ x ClickBots (10 clicks/second)
+
+
+
+ x BigClickBots (100 clicks/second)
+
+
+
+ Power
+
+ Power
+
+
+
+
+
+ Trees
+
+
+
+
+
+
+
+ Fruits
+
+ x
+
+
+
+
+
diff --git a/awesome_clicker/static/src/models/clicker_migration.js b/awesome_clicker/static/src/models/clicker_migration.js
new file mode 100644
index 00000000000..00ed31912ed
--- /dev/null
+++ b/awesome_clicker/static/src/models/clicker_migration.js
@@ -0,0 +1,27 @@
+export const currentVersion = 1;
+
+const migrations = [
+ {
+ fromVersion: 1,
+ toVersion: 2,
+ apply(clicker) {
+ clicker.trees.push(0);
+ clicker.fruits.push(0);
+ }
+ }
+]
+
+export function migrate(clicker) {
+ let didMigrate = false;
+
+ for(let migration of migrations) {
+ if(migration.fromVersion === clicker.version) {
+ migration.apply(clicker);
+ clicker.version = migration.toVersion;
+
+ didMigrate = true;
+ }
+ }
+
+ return didMigrate;
+}
diff --git a/awesome_clicker/static/src/models/clicker_model.js b/awesome_clicker/static/src/models/clicker_model.js
new file mode 100644
index 00000000000..d63b7e17ddf
--- /dev/null
+++ b/awesome_clicker/static/src/models/clicker_model.js
@@ -0,0 +1,216 @@
+import { Reactive } from "@web/core/utils/reactive";
+import { EventBus } from "@odoo/owl";
+import { registry } from "@web/core/registry";
+import { currentVersion } from "./clicker_migration";
+
+export const treeTypes = [
+ "pear", "cherry", "peach"
+]
+
+let buyables = {
+ clickBot: {
+ minLevel: 1,
+ price: 1000,
+ name: "clickBots",
+ onBuy: "triggetClickBotInterval"
+ },
+ bigClickBot: {
+ minLevel: 2,
+ price: 5000,
+ name: "bigClickBots",
+ onBuy: "triggetClickBotInterval"
+ },
+ power: {
+ minLevel: 3,
+ price: 100_000,
+ name: "power",
+ onBuy: ""
+ },
+}
+
+const allLevels = [
+ {minClicks: 1_000, eventName: "MILESTONE_1k"},
+ {minClicks: 5_000, eventName: "MILESTONE_5k"},
+ {minClicks: 100_000, eventName: "MILESTONE_100k"},
+ {minClicks: 1_000_000, eventName: "MILESTONE_1M"}
+]
+
+export const clickerModelKey = "clicker-model-key";
+
+export class ClickerModel extends Reactive {
+ clicks = 0;
+ level = 0;
+
+ clickBots = 0;
+ bigClickBots = 0;
+ power = 1;
+ clickBotInterval = null;
+
+ trees = new Array(treeTypes.length).fill(0);
+ fruits = new Array(treeTypes.length).fill(0);
+ treeInterval = null;
+
+ bus = new EventBus();
+
+ version = 0;
+
+ constructor(def = {}) {
+ super();
+
+ this.clicks = def.clicks ?? 0;
+ this.level = def.level ?? 0;
+ this.clickBots = def.clickBots ?? 0;
+ this.bigClickBots = def.bigClickBots ?? 0;
+ this.power = def.power ?? 1;
+ this.version = def.version ?? currentVersion;
+
+ if(def.fruits) {
+ for(let i = 0; i < this.fruits.length; i++) {
+ this.fruits[i] = def.fruits[i] ?? 0;
+ }
+ }
+
+ if(def.trees) {
+ for(let i = 0; i < this.trees.length; i++) {
+ this.trees[i] = def.trees[i] ?? 0;
+ }
+ }
+
+ if(this.clickBots > 0 || this.bigClickBots > 0) this.triggetClickBotInterval(this);
+ if(this.trees.find(t => t > 0)) this.triggerTreeInterval(this);
+ }
+
+ increment(val) {
+ this.clicks += val;
+
+ for(let i = this.level; i < allLevels.length; i++) {
+ let level = allLevels[i];
+ if(this.clicks >= level.minClicks) {
+ this.level = i + 1;
+ this.bus.trigger(level.eventName);
+ }
+ }
+ }
+
+ buyClickBot() {
+ this.buy(buyables.clickBot);
+ }
+
+ buyBigClickBot() {
+ this.buy(buyables.bigClickBot);
+ }
+
+ buyPower() {
+ this.buy(buyables.power);
+ }
+
+ getClickBot(count) {
+ this.get(buyables.clickBot, count);
+ }
+
+ getBigClickBot(count) {
+ this.get(buyables.bigClickBot, count);
+ }
+
+ getPower(count) {
+ this.get(buyables.power, count);
+ }
+
+ buyTree(fruitName) {
+ let index = treeTypes.indexOf(fruitName);
+ if(index < 0) return;
+
+ if(this.clicks < 1_000_000 || this.level < 4) return;
+
+ this.clicks -= 1_000_000;
+ this.trees[index]++;
+ this.triggerTreeInterval(this);
+ }
+
+ buy(buyable) {
+ if(this.clicks < buyable.price || this.level < buyable.minLevel) return;
+
+ this.clicks -= buyable.price;
+ this[buyable.name]++;
+
+ let onBuy = this[buyable.onBuy];
+ if(onBuy) onBuy(this);
+ }
+
+ get(buyable, count) {
+ if(this.level < buyable.minLevel) return;
+
+ this[buyable.name] += count;
+
+ let onBuy = this[buyable.onBuy];
+ if(onBuy) onBuy(this);
+ }
+
+ getTreeCount(fruitName) {
+ let index = treeTypes.indexOf(fruitName);
+ if(index < 0) return 0;
+
+ return this.trees[index];
+ }
+
+ getAllTreesCount() {
+ return this.trees.reduce((acc, t) => acc += t, 0);
+ }
+
+ getAllFruitsCount() {
+ return this.fruits.reduce((acc, t) => acc += t, 0);
+ }
+
+ getFruitCount(fruitName) {
+ let index = treeTypes.indexOf(fruitName);
+ if(index < 0) return 0;
+
+ return this.fruits[index];
+ }
+
+ triggetClickBotInterval(el) {
+ if(el.clickBotInterval === null) {
+ el.clickBotInterval = setInterval(() => {
+ el.clicks += el.clickBots * 10 * el.power;
+ el.clicks += el.bigClickBots * 100 * el.power;
+
+ localStorage.setItem(clickerModelKey, JSON.stringify(el));
+ }, 1000);
+ }
+ }
+
+ triggerTreeInterval(el) {
+ if(el.treeInterval === null) {
+ el.treeInterval = setInterval(() => {
+ for(let i = 0; i < treeTypes.length; i++) {
+ el.fruits[i] += el.trees[i];
+ }
+
+ localStorage.setItem(clickerModelKey, JSON.stringify(el));
+ }, 3000);
+ }
+ }
+}
+
+registry.category("command_provider").add("clicker", {
+ provide: (env) => {
+ return [{
+ action() {
+ env.services.action.doAction({
+ type: 'ir.actions.client',
+ tag: 'awesome_clicker.client_action',
+ target: 'new',
+ name: 'Clicker'
+ })
+ },
+ category: "clicker",
+ name: "Open Clicker Game"
+ },{
+ action() {
+ env.services.clicker.clicker.buyClickBot();
+ },
+ category: "Clicker",
+ name: "Buy 1 click bot"
+ }]
+ }
+})
diff --git a/awesome_clicker/static/src/models/client_reward.js b/awesome_clicker/static/src/models/client_reward.js
new file mode 100644
index 00000000000..33880416e14
--- /dev/null
+++ b/awesome_clicker/static/src/models/client_reward.js
@@ -0,0 +1,69 @@
+import { patch } from "@web/core/utils/patch"
+import { FormController } from "@web/views/form/form_controller"
+import { useClicker } from "../utility";
+import { useService } from "@web/core/utils/hooks";
+
+export const rewards = [
+ {
+ description: "Get 1 click bot",
+ apply(clicker) {
+ clicker.getClickBot(1);
+ },
+ minLevel: 1,
+ maxLevel: 3,
+ },
+ {
+ description: "Get 10 click bot",
+ apply(clicker) {
+ clicker.getClickBot(10);
+ },
+ minLevel: 3,
+ maxLevel: 4,
+ },
+ {
+ description: "Increase bot power!",
+ apply(clicker) {
+ clicker.getPower(1);
+ },
+ minLevel: 3,
+ },
+];
+
+export function getRandomReward(clicker) {
+ let choices = rewards.filter(r => (r.minLevel === undefined || r.minLevel <= clicker.level)
+ && (r.maxLevel === undefined || r.maxLevel >= clicker.level));
+
+ if(choices.length == 0) return null;
+
+ return choices[Math.floor(Math.random() * choices.length)]
+}
+
+const randomRewardChance = 1;
+
+patch(FormController.prototype, {
+ setup() {
+ super.setup();
+
+ if(Math.random() <= randomRewardChance) {
+ let clicker = useClicker();
+ let reward = getRandomReward(clicker);
+ if(!reward) return;
+
+ this.notification = useService("notification");
+ const closeNotification = this.notification.add(`"${reward.description}"`, {
+ title: "Congrats, you won a reward ",
+ type: "success",
+ sticky: true,
+ buttons: [
+ {
+ name: "Collect",
+ onClick: () => {
+ closeNotification();
+ reward.apply(clicker);
+ }
+ },
+ ],
+ });
+ }
+ }
+})
diff --git a/awesome_clicker/static/src/services/clicker_service.js b/awesome_clicker/static/src/services/clicker_service.js
new file mode 100644
index 00000000000..ea9dda6de73
--- /dev/null
+++ b/awesome_clicker/static/src/services/clicker_service.js
@@ -0,0 +1,38 @@
+import { registry } from "@web/core/registry";
+import { ClickerModel } from "../models/clicker_model";
+import { clickerModelKey } from "../models/clicker_model";
+import { migrate } from "../models/clicker_migration";
+
+const allEvents = [
+ {eventName: "MILESTONE_1k", description: "Milestone reached! You can now buy clickbots"},
+ {eventName: "MILESTONE_5k", description: "Milestone reached! You can now buy big clickbots"},
+ {eventName: "MILESTONE_100k", description: "Milestone reached! You can now buy power"},
+ {eventName: "MILESTONE_1M", description: "Milestone reached! You can now buy trees"}
+]
+
+const clickerService = {
+ dependencies: ["effect"],
+ start(env, {effect}) {
+ let stored = localStorage.getItem(clickerModelKey);
+ let clicker;
+
+ if(stored) {
+ clicker = new ClickerModel(JSON.parse(stored));
+ if(migrate(clicker)) localStorage.setItem(clickerModelKey, JSON.stringify(clicker));
+ }
+ else clicker = new ClickerModel();
+
+ for(let event of allEvents) {
+ clicker.bus.addEventListener(event.eventName, () => {
+ effect.add({
+ type: 'rainbow_man',
+ message: event.description
+ })
+ })
+ }
+
+ return { clicker }
+ }
+}
+
+registry.category("services").add("clicker", clickerService);
diff --git a/awesome_clicker/static/src/utility.js b/awesome_clicker/static/src/utility.js
new file mode 100644
index 00000000000..a0ab2203426
--- /dev/null
+++ b/awesome_clicker/static/src/utility.js
@@ -0,0 +1,8 @@
+import { useService } from "@web/core/utils/hooks";
+import { useState } from "@odoo/owl";
+
+export function useClicker() {
+ let service = useService("clicker");
+
+ return useState(service.clicker)
+}
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.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/dash_item_dialog/dashboard_items_dialog.js b/awesome_dashboard/static/src/dashboard/dash_item_dialog/dashboard_items_dialog.js
new file mode 100644
index 00000000000..40637b0ac54
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dash_item_dialog/dashboard_items_dialog.js
@@ -0,0 +1,33 @@
+import { Dialog } from "@web/core/dialog/dialog";
+import { Component } from "@odoo/owl";
+import { useService } from "@web/core/utils/hooks";
+
+export class DashboardItemDialog extends Component {
+ static template = "awesome_dashboard.dashboard_items_dialog";
+ static components = {
+ Dialog,
+ };
+
+ setup() {
+ this.service = useService("dashboard_items");
+ this.allItems = this.service.getAllItems();
+ this.usedIds = this.service.getUsedIds();
+ }
+
+ isSelected(id) {
+ return this.usedIds.includes(id);
+ }
+
+ select(id) {
+ return () => {
+ let found = this.usedIds.indexOf(id);
+ if(found >= 0) this.usedIds.splice(found, 1);
+ else this.usedIds.push(id);
+ }
+ }
+
+ apply() {
+ this.service.setUsedIds(this.usedIds);
+ this.props?.close()
+ }
+}
diff --git a/awesome_dashboard/static/src/dashboard/dash_item_dialog/dashboard_items_dialog.xml b/awesome_dashboard/static/src/dashboard/dash_item_dialog/dashboard_items_dialog.xml
new file mode 100644
index 00000000000..36f9e7d3a96
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dash_item_dialog/dashboard_items_dialog.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js
new file mode 100644
index 00000000000..a207a442bed
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard.js
@@ -0,0 +1,40 @@
+import { Component, 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 "./dashboard_item/dashboard_item"
+import { DashboardItemDialog } from "./dash_item_dialog/dashboard_items_dialog";
+
+export class AwesomeDashboard extends Component {
+ static template = "awesome_dashboard.AwesomeDashboard";
+
+ static components = {
+ Layout,
+ DashboardItem
+ }
+
+ setup() {
+ this.items = useState(useService("dashboard_items").getUsedItems());
+ this.action = useService("action")
+ this.stats = useState(useService("statistics").loadStatistics());
+ this.dialog = useService("dialog");
+ }
+
+ openCustomers() {
+ this.action.doAction("base.action_partner_form")
+ }
+
+ openLeads() {
+ this.action.doAction({
+ type: 'ir.actions.act_window',
+ res_model: 'crm.lead',
+ views: [[false, 'list'], [false, 'form']]
+ })
+ }
+
+ openItemsDialog() {
+ this.dialog.add(DashboardItemDialog)
+ }
+}
+
+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..6af4f389665
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard.scss
@@ -0,0 +1,13 @@
+.o_dashboard {
+ background-color: azure;
+ display: flex;
+ width: 100%;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-content: start;
+}
+
+.green_value {
+ color: green;
+ font-size: 3rem;
+}
diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml
new file mode 100644
index 00000000000..7e68772189f
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js
new file mode 100644
index 00000000000..ee138c0d99c
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js
@@ -0,0 +1,10 @@
+import { Component } from "@odoo/owl";
+
+export class DashboardItem extends Component {
+ static template = "awesome_dashboard.dashboard_item";
+
+ static props = {
+ size: {type:Number, default: 1},
+ slots: {type: Object, optional: true}
+ }
+}
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.scss b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.scss
new file mode 100644
index 00000000000..450ca3e0105
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.scss
@@ -0,0 +1,11 @@
+.card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ background: white;
+ border-radius: 0.5rem;
+ border: none;
+ padding: 1rem;
+ margin: 0.5rem;
+ height: fit-content;
+}
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml
new file mode 100644
index 00000000000..12604f4ecc3
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
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..92e0d45a272
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js
@@ -0,0 +1,68 @@
+import { PieChartCard } from "./dashboard_items/pie_char_card"
+import { NumberCard } from "./dashboard_items/number_card"
+import { registry } from "@web/core/registry";
+
+const items = [
+ {
+ id: "average_quantity",
+ description: "Average amount of t-shirt",
+ Component: NumberCard,
+ size: 1,
+ props: (data) => ({
+ title: "Average amount of t-shirt by order this month",
+ value: data.average_quantity
+ }),
+ },
+ {
+ id: "average_time",
+ description: "Average time",
+ Component: NumberCard,
+ size: 1,
+ props: (data) => ({
+ title: "Average time for an order to go from 'new' to 'sent' or 'cancelled'",
+ value: data.average_time
+ }),
+ },
+ {
+ id: "nb_new_orders",
+ description: "Number of new orders",
+ Component: NumberCard,
+ size: 1,
+ props: (data) => ({
+ title: "Number of new orders this month",
+ value: data.nb_new_orders
+ }),
+ },
+ {
+ id: "nb_cancelled_orders",
+ description: "Number of cancelled orders",
+ Component: NumberCard,
+ size: 1,
+ props: (data) => ({
+ title: "Number of cancelled orders this month",
+ value: data.nb_cancelled_orders
+ }),
+ },
+ {
+ id: "total_amount",
+ description: "Total amount of new orders",
+ Component: NumberCard,
+ size: 1,
+ props: (data) => ({
+ title: "Total amount of new orders this month",
+ value: data.total_amount
+ }),
+ },
+ {
+ id: "orders_by_size",
+ description: "Shirt orders by size",
+ Component: PieChartCard,
+ size: 2,
+ props: (data) => ({
+ title: "Shirt orders by size",
+ value: data.orders_by_size
+ }),
+ },
+ ]
+
+registry.category("awesome_dashboard").add("items", items);
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_items/number_card.js b/awesome_dashboard/static/src/dashboard/dashboard_items/number_card.js
new file mode 100644
index 00000000000..ad2a98f9160
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_items/number_card.js
@@ -0,0 +1,10 @@
+import { Component } from "@odoo/owl";
+
+export class NumberCard extends Component {
+ static template = "awesome_dashboard.NumberCard"
+
+ static props = {
+ title: String,
+ value: Number
+ }
+}
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_items/number_card.xml b/awesome_dashboard/static/src/dashboard/dashboard_items/number_card.xml
new file mode 100644
index 00000000000..45454142519
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_items/number_card.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_items/pie_char_card.js b/awesome_dashboard/static/src/dashboard/dashboard_items/pie_char_card.js
new file mode 100644
index 00000000000..96bb730e390
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_items/pie_char_card.js
@@ -0,0 +1,43 @@
+import { Component, onWillStart, useRef, onMounted, onWillPatch, useState } from "@odoo/owl";
+import { loadJS } from "@web/core/assets"
+
+export class PieChartCard extends Component {
+ static template = "awesome_dashboard.PieChartCard"
+
+ static props = {
+ title: String,
+ value: {type: Object, optional: true}
+ }
+
+ chart = null;
+
+ drawChart() {
+ if(!this.props.value) return;
+
+ if(this.chart) {
+ this.chart.data.datasets[0].data = Object.values(this.props.value);
+ this.chart.update();
+ return;
+ }
+
+ this.chart = new Chart(this.canvasRef.el, {
+ type: 'pie',
+ data: {
+ labels: Object.keys(this.props.value),
+ datasets: [
+ {
+ label: 'Shirts',
+ data: Object.values(this.props.value)
+ }
+ ]
+ }
+ })
+ }
+
+ setup() {
+ this.canvasRef = useRef("canvas");
+ onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js"));
+ onMounted(() => this.drawChart())
+ onWillPatch(() => this.drawChart())
+ }
+}
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_items/pie_chart_card.xml b/awesome_dashboard/static/src/dashboard/dashboard_items/pie_chart_card.xml
new file mode 100644
index 00000000000..c6b5c12d7a3
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_items/pie_chart_card.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/services/dashboard_items.service.js b/awesome_dashboard/static/src/dashboard/services/dashboard_items.service.js
new file mode 100644
index 00000000000..77cab8c07b3
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/services/dashboard_items.service.js
@@ -0,0 +1,43 @@
+import { registry } from "@web/core/registry";
+import { reactive } from "@odoo/owl";
+
+const storageKey = "owl-dashboard-used-dashboard-items"
+
+function setUsedItems(result, allItems, usedIds) {
+ result.splice(0, result.length);
+
+ for(let id of usedIds) {
+ let found = allItems.find((i) => i.id == id);
+ result.push(found);
+ }
+}
+
+const dashboardItemsService = {
+ start() {
+ let fromStorage = localStorage.getItem(storageKey);
+ let usedIds = fromStorage ? JSON.parse(fromStorage) : [];
+ let allItems = registry.category("awesome_dashboard").get("items");
+ let usedItems = reactive([]);
+
+ setUsedItems(usedItems, allItems, usedIds);
+
+ return {
+ getUsedItems() {
+ return usedItems;
+ },
+ getAllItems() {
+ return allItems;
+ },
+ getUsedIds() {
+ return usedIds.slice();
+ },
+ setUsedIds(ids) {
+ usedIds = ids;
+ localStorage.setItem(storageKey, JSON.stringify(ids));
+ setUsedItems(usedItems, allItems, usedIds);
+ }
+ }
+ }
+}
+
+registry.category("services").add("dashboard_items", dashboardItemsService);
diff --git a/awesome_dashboard/static/src/dashboard/services/statistics.service.js b/awesome_dashboard/static/src/dashboard/services/statistics.service.js
new file mode 100644
index 00000000000..f1a9f55b38d
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/services/statistics.service.js
@@ -0,0 +1,30 @@
+import { registry } from "@web/core/registry";
+import { rpc } from "@web/core/network/rpc";
+import { reactive } from "@odoo/owl";
+
+const fetchInterval = 3000;
+
+async function fetchStatistics(stats) {
+ const result = await rpc("/awesome_dashboard/statistics");
+
+ for(let entry of Object.entries(result)) {
+ if(stats[entry[0]] !== undefined) stats[entry[0]] = entry[1];
+ }
+}
+
+const statisticsService = {
+ start() {
+ let stats = reactive({average_quantity: 0, average_time: 0, nb_cancelled_orders: 0, nb_new_orders: 0, total_amount: 0, orders_by_size: {}});
+
+ fetchStatistics(stats)
+ setInterval(() => fetchStatistics(stats), fetchInterval);
+
+ return {
+ loadStatistics() {
+ return stats;
+ }
+ }
+ }
+}
+
+registry.category("services").add("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..f09712a4459
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard_action.js
@@ -0,0 +1,18 @@
+import { Component, xml } from "@odoo/owl";
+import { LazyComponent } from "@web/core/assets";
+import { AwesomeDashboard } from "./dashboard/dashboard";
+import { registry } from "@web/core/registry";
+
+class AwesomeDashboardLoader extends Component {
+ static components = {
+ LazyComponent,
+ AwesomeDashboard
+ };
+ static template = xml`
+
+ `;
+}
+
+registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboardLoader);
+
+
diff --git a/awesome_owl/__manifest__.py b/awesome_owl/__manifest__.py
index 55002ab81de..e4fb0502d24 100644
--- a/awesome_owl/__manifest__.py
+++ b/awesome_owl/__manifest__.py
@@ -36,7 +36,7 @@
('include', 'web._assets_bootstrap'),
('include', 'web._assets_core'),
'web/static/src/libs/fontawesome/css/font-awesome.css',
- 'awesome_owl/static/src/**/*',
+ 'awesome_owl/static/src/**/*'
],
},
'license': 'AGPL-3'
diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js
new file mode 100644
index 00000000000..303d31cd2de
--- /dev/null
+++ b/awesome_owl/static/src/card/card.js
@@ -0,0 +1,23 @@
+import { Component, useState } from "@odoo/owl";
+
+export class Card extends Component {
+ static template = "awesome_owl.card";
+
+ static props = {
+ title: {type: String, validate: val => {
+ if(val.length === 0) return false;
+
+ let first = val.substring(0, 1);
+ return first === first.toUpperCase();
+ }},
+ slots: {type: Object, optional: true},
+ }
+
+ setup() {
+ this.state = useState({open: true});
+ }
+
+ toggle(){
+ this.state.open = !this.state.open;
+ }
+}
diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml
new file mode 100644
index 00000000000..fa2adf3bfa9
--- /dev/null
+++ b/awesome_owl/static/src/card/card.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js
new file mode 100644
index 00000000000..bfccd6ea4ad
--- /dev/null
+++ b/awesome_owl/static/src/counter/counter.js
@@ -0,0 +1,18 @@
+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++;
+ 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..5e0ec2135c0
--- /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 4ac769b0aa5..e2124b07784 100644
--- a/awesome_owl/static/src/playground.js
+++ b/awesome_owl/static/src/playground.js
@@ -1,5 +1,25 @@
-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_list/todo_list";
export class Playground extends Component {
static template = "awesome_owl.playground";
+
+ someHtml = "
some content
"
+ markupedHtml = markup(this.someHtml);
+
+ static components = {
+ Counter,
+ Card,
+ TodoList
+ };
+
+ setup() {
+ this.state = useState({ sum: 2 });
+ }
+
+ incrementSum() {
+ this.state.sum++;
+ }
}
diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml
index 4fb905d59f9..32b66270c7f 100644
--- a/awesome_owl/static/src/playground.xml
+++ b/awesome_owl/static/src/playground.xml
@@ -3,8 +3,25 @@
- hello world
+ hello word
+
+
+
+ This is working
+
+
+
+
+
+
+
+
+
+
+
+ The sum :
+
diff --git a/awesome_owl/static/src/todo_item/todo_item.js b/awesome_owl/static/src/todo_item/todo_item.js
new file mode 100644
index 00000000000..412bacc74a4
--- /dev/null
+++ b/awesome_owl/static/src/todo_item/todo_item.js
@@ -0,0 +1,19 @@
+import { Component } from "@odoo/owl";
+
+export class TodoItem extends Component {
+ static template = "awesome_owl.todo_item";
+
+ static props = {
+ todo: {type: Object, shape: {id: Number, description: String, isCompleted: Boolean}},
+ toggleState: Function,
+ removeTodo: Function
+ }
+
+ complete() {
+ this.props.toggleState(this.props.todo.id)
+ }
+
+ remove() {
+ this.props.removeTodo(this.props.todo.id)
+ }
+}
diff --git a/awesome_owl/static/src/todo_item/todo_item.xml b/awesome_owl/static/src/todo_item/todo_item.xml
new file mode 100644
index 00000000000..68da38622b2
--- /dev/null
+++ b/awesome_owl/static/src/todo_item/todo_item.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ .
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/todo_list/todo_list.js b/awesome_owl/static/src/todo_list/todo_list.js
new file mode 100644
index 00000000000..923b3468819
--- /dev/null
+++ b/awesome_owl/static/src/todo_list/todo_list.js
@@ -0,0 +1,38 @@
+import { Component, useState } from "@odoo/owl";
+import { TodoItem } from "../todo_item/todo_item";
+import { useAutofocus } from "../util";
+
+export class TodoList extends Component {
+ static template = "awesome_owl.todo_list";
+
+ counter = 1;
+
+ static components = {
+ TodoItem
+ }
+
+ setup() {
+ this.todos = useState([]);
+ useAutofocus("input");
+ }
+
+ change(id) {
+ let found = this.todos.findIndex(t => t.id === id);
+ if(found >= 0) this.todos[found] = {...this.todos[found], isCompleted: !this.todos[found].isCompleted};
+ }
+
+ remove(id) {
+ let found = this.todos.findIndex(t => t.id === id);
+ if(found >= 0) this.todos.splice(found, 1);
+ }
+
+ tryAdd(ev) {
+ if(ev.keyCode != 13) return;
+
+ let txt = ev.srcElement.value;
+ if(txt === "") return;
+
+ this.todos.push({id: this.counter++, description: txt, isCompleted: false});
+ ev.srcElement.value = "";
+ }
+}
diff --git a/awesome_owl/static/src/todo_list/todo_list.xml b/awesome_owl/static/src/todo_list/todo_list.xml
new file mode 100644
index 00000000000..abc59016150
--- /dev/null
+++ b/awesome_owl/static/src/todo_list/todo_list.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/util.js b/awesome_owl/static/src/util.js
new file mode 100644
index 00000000000..c5a84892a3c
--- /dev/null
+++ b/awesome_owl/static/src/util.js
@@ -0,0 +1,8 @@
+import { useRef, onMounted } from "@odoo/owl";
+
+export function useAutofocus(refName) {
+ let ref = useRef(refName);
+ onMounted(() => {
+ ref.el?.focus()
+ })
+}
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..7378b74ef59
--- /dev/null
+++ b/estate/__manifest__.py
@@ -0,0 +1,21 @@
+{
+ 'name': 'Real Estate',
+ 'author': 'zavan',
+ 'depends': ['base'],
+ 'application': True,
+ 'license': 'LGPL-3',
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'views/estate_property_type_views.xml',
+ 'views/estate_property_tag_views.xml',
+ 'views/estate_property_offer_views.xml',
+ 'views/estate_property_views.xml',
+ 'views/estate_menus.xml',
+ 'views/res_users_views.xml'
+ ],
+ 'demo': [
+ 'demo/estate.property.type.csv',
+ 'demo/estate.property.xml',
+ 'demo/estate.property.offer.xml'
+ ]
+}
diff --git a/estate/demo/estate.property.offer.xml b/estate/demo/estate.property.offer.xml
new file mode 100644
index 00000000000..3354a1a10d0
--- /dev/null
+++ b/estate/demo/estate.property.offer.xml
@@ -0,0 +1,34 @@
+
+
+ 10000
+ 14
+
+
+
+
+
+ 1500000
+ 14
+
+
+
+
+
+ 1500001
+ 14
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/estate/demo/estate.property.type.csv b/estate/demo/estate.property.type.csv
new file mode 100644
index 00000000000..e65cbe5c724
--- /dev/null
+++ b/estate/demo/estate.property.type.csv
@@ -0,0 +1,5 @@
+"id","name"
+estate_property_type_residential,"Residential"
+estate_property_type_commercial,"Commercial"
+estate_property_type_industrial,"Industrial"
+estate_property_type_land,"Land"
diff --git a/estate/demo/estate.property.xml b/estate/demo/estate.property.xml
new file mode 100644
index 00000000000..2a523a1334b
--- /dev/null
+++ b/estate/demo/estate.property.xml
@@ -0,0 +1,33 @@
+
+
+ Big Villa
+ new
+ A nice and big villa
+ 12345
+ 2020-02-02
+ 1600000
+ 6
+ 100
+ 4
+ True
+ True
+ 100000
+ south
+
+
+
+
+ Trailer home
+ cancelled
+ Home in a trailer park
+ 54321
+ 1970-01-01
+ 100000
+ 120000
+ 1
+ 10
+ 4
+ False
+
+
+
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..eaca1278bbd
--- /dev/null
+++ b/estate/models/estate_property.py
@@ -0,0 +1,92 @@
+from odoo import api, fields, models, exceptions
+from dateutil.relativedelta import relativedelta
+from odoo.tools.float_utils import float_compare, float_is_zero
+
+
+class EstateProperty(models.Model):
+ _name = 'estate.property'
+ _description = "Real estate property"
+ _order = "id desc"
+
+ _check_expected_price = models.Constraint('CHECK(expected_price > 0)', 'The expected price should always be positive')
+ _check_selling_price = models.Constraint('CHECK(selling_price >= 0)', 'The selling price should always be positive')
+
+ name = fields.Char(required=True)
+ description = fields.Text()
+ postcode = fields.Char()
+ date_availability = fields.Date("Available From", copy=False, default=fields.Date.today() + relativedelta(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()
+ facades = fields.Integer()
+ garage = fields.Boolean()
+ garden = fields.Boolean()
+ garden_area = fields.Integer()
+ garden_orientation = fields.Selection(selection=[('north', 'North'), ('south', 'South'), ('east', 'East'), ('west', 'West')])
+ active = fields.Boolean(default=True)
+ state = fields.Selection(
+ selection=[('new', 'New'), ('offer-received', 'Offer Received'), ('offer-accepted', 'Offer Accepted'), ('sold', 'Sold'), ('cancelled', 'Cancelled')],
+ copy=False, default="new",
+ required=True
+ )
+ property_type_id = fields.Many2one("estate.property.type", string="Type")
+ buyer_id = fields.Many2one("res.partner", string="Buyer", copy=False, readonly=True)
+ salesperson_id = fields.Many2one("res.users", string="Salesperson", default=lambda self: self.env.user)
+ tag_ids = fields.Many2many("estate.property.tag", string="Tags")
+ 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")
+
+ @api.depends("garden_area", "living_area")
+ def _compute_total_area(self):
+ for record in self:
+ record.total_area = record.garden_area + record.living_area
+
+ @api.depends("offer_ids")
+ def _compute_best_price(self):
+ for prop in self:
+ prop.best_price = max((offer.price for offer in prop.offer_ids), default=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 sell_property(self):
+ for prop in self:
+ if (prop.state == 'sold'):
+ continue
+ elif (prop.state == 'cancelled'):
+ raise exceptions.UserError('Cannot sell a cancelled property')
+ elif (prop.state != 'offer-accepted'):
+ raise exceptions.UserError('Cannot sell a property with no accepted offer')
+ else:
+ prop.state = 'sold'
+ return True
+
+ def cancel_property(self):
+ for prop in self:
+ if (prop.state == 'cancelled'):
+ continue
+ elif (prop.state == 'sold'):
+ raise exceptions.UserError('Cannot cancel a sold property')
+ else:
+ prop.state = 'cancelled'
+ return True
+
+ @api.constrains("selling_price", "expected_price")
+ def _check_price(self):
+ for prop in self:
+ if (not (prop.selling_price is None or float_is_zero(prop.selling_price, precision_digits=2)) and
+ float_compare(prop.selling_price, prop.expected_price * 9 / 10, precision_digits=2) < 0):
+ raise exceptions.ValidationError("The selling price cannot be below 90 percent of the expected price")
+
+ @api.ondelete(at_uninstall=False)
+ def _unlink_if_new_or_cancelled(self):
+ if any(prop.state != 'new' and prop.state != 'cancelled' for prop in self):
+ raise exceptions.UserError("Can only delete new or cancelled properties")
diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py
new file mode 100644
index 00000000000..74cd4f9c969
--- /dev/null
+++ b/estate/models/estate_property_offer.py
@@ -0,0 +1,64 @@
+from odoo import api, fields, models, exceptions
+from dateutil.relativedelta import relativedelta
+import datetime
+
+
+class EstatePropertyOffer(models.Model):
+ _name = "estate.property.offer"
+ _description = "Estate property offer"
+ _order = "price desc"
+
+ _check_price = models.Constraint('CHECK(price > 0)', 'The price should always be positive')
+
+ price = fields.Float()
+ status = fields.Selection(readonly=True, 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(default=7)
+ date_deadline = fields.Date(compute="_compute_deadline", inverse="_inverse_validity", readonly=False)
+ property_type_id = fields.Many2one(related="property_id.property_type_id")
+
+ @api.model
+ def create(self, vals_list):
+ prop = self.env['estate.property'].browse(vals_list[0]['property_id'])
+
+ if (any(o.price > vals_list[0]['price'] for o in prop.offer_ids)):
+ raise exceptions.UserError("Cannot add an offer with a lower amount than an existing one")
+
+ prop.state = "offer-received"
+
+ return super().create(vals_list)
+
+ @api.depends("validity", "create_date")
+ def _compute_deadline(self):
+ for offer in self:
+ create = offer.create_date.date() if isinstance(offer.create_date, datetime.datetime) else fields.Date.today()
+ offer.date_deadline = create + relativedelta(days=offer.validity)
+
+ def _inverse_validity(self):
+ for offer in self:
+ create = offer.create_date.date() if isinstance(offer.create_date, datetime.datetime) else fields.Date.today()
+ offer.validity = (offer.date_deadline - create).days
+
+ def accept_offer(self):
+ for offer in self:
+ if (offer.status == 'accepted'):
+ continue
+
+ for other in offer.property_id.offer_ids:
+ if (other.status == 'accepted'):
+ raise exceptions.UserError("Cannot accept multiple offers for a single property")
+
+ offer.status = 'accepted'
+ offer.property_id.buyer_id = offer.partner_id
+ offer.property_id.selling_price = offer.price
+ offer.property_id.state = "offer-accepted"
+ return True
+
+ def refuse_offer(self):
+ for offer in self:
+ if (offer.status == 'accepted'):
+ offer.property_id.buyer_id = None
+ offer.property_id.selling_price = None
+ offer.status = 'refused'
+ return True
diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py
new file mode 100644
index 00000000000..d2dbd0e6a0f
--- /dev/null
+++ b/estate/models/estate_property_tag.py
@@ -0,0 +1,12 @@
+from odoo import fields, models
+
+
+class EstatePropertyTag(models.Model):
+ _name = "estate.property.tag"
+ _description = "Estate property tag"
+ _order = "name"
+
+ _unique_name = models.Constraint("UNIQUE (name)", "A tag should be unique")
+
+ name = fields.Char(required=True)
+ color = fields.Integer()
diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py
new file mode 100644
index 00000000000..bbad0fafb2d
--- /dev/null
+++ b/estate/models/estate_property_type.py
@@ -0,0 +1,20 @@
+from odoo import api, fields, models
+
+
+class EstatePropertyType(models.Model):
+ _name = "estate.property.type"
+ _description = "Real estate property type"
+ _order = "name"
+
+ _unique_name = models.Constraint("UNIQUE (name)", "A property type should be unique")
+
+ name = fields.Char(required=True)
+ sequence = fields.Integer(default=1)
+ property_ids = fields.One2many("estate.property", "property_type_id")
+ offer_ids = fields.One2many("estate.property.offer", "property_type_id")
+ offer_count = fields.Integer(compute="_compute_offer_count")
+
+ @api.depends("offer_ids")
+ def _compute_offer_count(self):
+ for prop_type in self:
+ prop_type.offer_count = len(prop_type.offer_ids)
diff --git a/estate/models/res_users.py b/estate/models/res_users.py
new file mode 100644
index 00000000000..54cef1202b7
--- /dev/null
+++ b/estate/models/res_users.py
@@ -0,0 +1,7 @@
+from odoo import fields, models
+
+
+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..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..1081c03c37f
--- /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..61e6c51230c
--- /dev/null
+++ b/estate/views/estate_property_offer_views.xml
@@ -0,0 +1,35 @@
+
+
+
+ estate.property.offer.list
+ estate.property.offer
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.offer.form
+ estate.property.offer
+
+
+
+
+
diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml
new file mode 100644
index 00000000000..b043cee2bd0
--- /dev/null
+++ b/estate/views/estate_property_tag_views.xml
@@ -0,0 +1,18 @@
+
+
+
+ estate.property.tag.list
+ estate.property.tag
+
+
+
+
+
+
+
+
+ Estate Property Tag
+ estate.property.tag
+ list,form
+
+
diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml
new file mode 100644
index 00000000000..db08a2a54ec
--- /dev/null
+++ b/estate/views/estate_property_type_views.xml
@@ -0,0 +1,56 @@
+
+
+
+ estate.property.type.list
+ estate.property.type
+
+
+
+
+
+
+
+
+
+ Estate Property Offer
+ estate.property.offer
+ list
+ [('property_type_id', '=', active_id)]
+
+
+
+ estate.property.type.form
+ estate.property.type
+
+
+
+
+
+
+ Estate Property Type
+ 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..c9e13db954c
--- /dev/null
+++ b/estate/views/estate_property_views.xml
@@ -0,0 +1,134 @@
+
+
+
+ estate.property.form
+ estate.property
+
+
+
+
+
+
+ estate.property.list
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.search
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.kanban
+ estate.property
+
+
+
+
+
+
+
+
+
+ Expected Price :
+
+
+
+ Best Price :
+
+
+
+ Selling Price :
+
+
+
+
+
+
+
+
+
+
+ Estate Property
+ estate.property
+ kanban,list,form
+ {'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..1e0afe33495
--- /dev/null
+++ b/estate/views/res_users_views.xml
@@ -0,0 +1,15 @@
+
+
+
+ res.users.view.form.inherit.gamification
+ 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..8a89d41ac35
--- /dev/null
+++ b/estate_account/__manifest__.py
@@ -0,0 +1,6 @@
+{
+ 'name': 'Real Estate Accounting',
+ 'author': 'zavan',
+ 'depends': ['estate', 'account'],
+ '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..9d92998f846
--- /dev/null
+++ b/estate_account/models/estate_property.py
@@ -0,0 +1,27 @@
+from odoo import models, Command
+
+
+class EstateProperty(models.Model):
+ _inherit = ["estate.property"]
+
+ def sell_property(self):
+ for prop in self:
+ vals = {
+ 'partner_id': prop.buyer_id.id,
+ 'move_type': 'out_invoice',
+ 'journal_id': 1,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': '6 percent of selling price',
+ 'quantity': 1,
+ 'price_unit': (prop.selling_price * 6 / 100)
+ }),
+ Command.create({
+ 'name': 'Administrative fees',
+ 'quantity': 1,
+ 'price_unit': 100
+ })
+ ]
+ }
+ self.env['account.move'].create(vals)
+ return super().sell_property()