diff --git a/awesome_clicker/static/src/click_reward.js b/awesome_clicker/static/src/click_reward.js new file mode 100644 index 00000000000..451ab41a4f1 --- /dev/null +++ b/awesome_clicker/static/src/click_reward.js @@ -0,0 +1,34 @@ +import { choose } from "./utils"; + + +const rewards = [ + { + description: "Get 1 click bot", + apply(clicker) { + clicker.increment(1); + }, + maxLevel: 3, + }, + { + description: "Get 10 click bots", + apply(clicker) { + clicker.increment(10); + }, + minLevel: 3, + maxLevel: 4, + }, + { + description: "Increase bot power", + apply(clicker) { + clicker.power += 1; + }, + minLevel: 3, + }, +]; + +export function getReward(level){ + const availableRewards = rewards.filter((reward) => { + return (level >= (reward?.minLevel || level)) && (level <= (reward?.maxLevel || level)); + }); + return choose(availableRewards); +} diff --git a/awesome_clicker/static/src/click_value/click_value.js b/awesome_clicker/static/src/click_value/click_value.js new file mode 100644 index 00000000000..b8be5f33296 --- /dev/null +++ b/awesome_clicker/static/src/click_value/click_value.js @@ -0,0 +1,16 @@ +import { Component } from "@odoo/owl"; +import { humanNumber } from "@web/core/utils/numbers"; +import { useClicker } from "../clicker_hook"; + + +export class ClickValue extends Component { + static template = "awesome_clicker.click_value"; + + setup() { + this.clicker = useClicker(); + } + + get clicks(){ + return humanNumber(this.clicker.clicks, { decimals: 1 }); + } +} diff --git a/awesome_clicker/static/src/click_value/click_value.xml b/awesome_clicker/static/src/click_value/click_value.xml new file mode 100644 index 00000000000..7282c516501 --- /dev/null +++ b/awesome_clicker/static/src/click_value/click_value.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/awesome_clicker/static/src/clicker_hook.js b/awesome_clicker/static/src/clicker_hook.js new file mode 100644 index 00000000000..120cd0daaf0 --- /dev/null +++ b/awesome_clicker/static/src/clicker_hook.js @@ -0,0 +1,7 @@ +import { useService } from "@web/core/utils/hooks"; +import { useState } from "@odoo/owl"; + + +export function useClicker() { + return useState(useService("clickerService")); +} diff --git a/awesome_clicker/static/src/clicker_model.js b/awesome_clicker/static/src/clicker_model.js new file mode 100644 index 00000000000..38dbbbb11d5 --- /dev/null +++ b/awesome_clicker/static/src/clicker_model.js @@ -0,0 +1,128 @@ +/* @odoo-module */ +import { Reactive } from "@web/core/utils/reactive"; +import { EventBus } from "@odoo/owl"; +import { getReward } from "./click_reward"; + + +export class ClickerModel extends Reactive { + constructor(state){ + super(); + this._clicks = state?._clicks || 0; + this._level = state?._level || 0; + this._power = state?._power || 1; + this._bots = { + clickBot: { + price: 1000, + level: 1, + increment: 10, + purchased: state?._bots?.clickBot?.purchased || 0, + }, + bigBot: { + price: 5000, + level: 2, + increment: 100, + purchased: state?._bots?.bigBot?.purchased || 0, + }, + }; + this.trees = { + pearTree: { + price: 1000000, + level: 4, + produce: "pear", + purchased: state?.trees?.pearTree?.purchased || 0 + }, + cherryTree: { + price: 1000000, + level: 4, + produce: "cherry", + purchased: state?.trees?.cherryTree?.purchased || 0 + }, + }; + this.fruits = { + pear: state?.fruits?.pear || 0, + cherry: state?.fruits?.cherry || 0, + }; + this._milestones = [ + { clicks: 1000, unlock: "clickBot" }, + { clicks: 5000, unlock: "bigBot" }, + { clicks: 100000, unlock: "power multiplier" }, + { clicks: 1000000, unlock: "trees" }, + ]; + this._bus = new EventBus(); + this._tick = state?._tick || 0; + } + + setup(){} + + get clicks(){ return this._clicks; } + + get level(){ return this._level; } + + get power() { return this._power; } + set power(pwr){ this._power = pwr; } + + get bots() { return this._bots; } + + get firePower(){ + let sum = 0; + for(const bot in this._bots){ + sum += this._bots[bot].purchased * this._bots[bot].increment * this._power; + } + return sum; + } + + get bus(){ return this._bus; } + + increment(inc){ + this._clicks += inc; + if(this._milestones[this._level] + && this._clicks >= this._milestones[this._level].clicks){ + this.bus.trigger("MILESTONE", this._milestones[this._level]); + this._level++; + } + } + + tick(){ + this._tick++; + for(const bot in this._bots){ + this.increment(this._bots[bot].increment * this._power * this._bots[bot].purchased); + } + if(this._tick === 3){ + for(const tree in this.trees){ + this.fruits[this.trees[tree].produce] += this.trees[tree].purchased; + } + this._tick = 0; + } + } + + 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.increment(-this._bots[name].price); + this._bots[name].purchased++; + } + + buyPower(nb){ + if(this._clicks >= 50000 && this._level >= 3){ + this._power += nb; + this.increment(-50000*nb); + } + } + + buyTree(name){ + if(this._clicks >= this.trees[name].price){ + this.trees[name].purchased++; + this.increment(-this.trees[name].price); + } + } + + getReward(){ + const reward = getReward(this._level); + this._bus.trigger("REWARD", reward); + } + +} diff --git a/awesome_clicker/static/src/clicker_provider.js b/awesome_clicker/static/src/clicker_provider.js new file mode 100644 index 00000000000..4a5bc8d95a2 --- /dev/null +++ b/awesome_clicker/static/src/clicker_provider.js @@ -0,0 +1,31 @@ +/* @odoo-module */ +import { registry } from "@web/core/registry"; + + +const clickerProviderRegistry = registry.category("command_provider"); + +clickerProviderRegistry.add("clicker", { + provide: (env, options) => { + return [ + { + action(){ + env.services["clickerService"].buyBot(1); + }, + category: "clicker", + name: "Buy 1 click bot" + }, + { + action(){ + env.services.action.doAction({ + type: "ir.actions.client", + tag: "awesome_clicker.client_action", + target: "new", + name: "Clicker Game" + }); + }, + category: "clicker", + name: "Open Clicker Game" + }, + ] + } +}); diff --git a/awesome_clicker/static/src/clicker_service.js b/awesome_clicker/static/src/clicker_service.js new file mode 100644 index 00000000000..e712ebc1947 --- /dev/null +++ b/awesome_clicker/static/src/clicker_service.js @@ -0,0 +1,63 @@ +import { registry } from "@web/core/registry"; +import { ClickerModel } from "./clicker_model"; +import { EventBus } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { browser } from "@web/core/browser/browser"; + + +const clickerService = { + dependencies: ["action", "effect", "notification"], + start(env, services){ + const clicker_state_storage = browser.localStorage.getItem("clicker_state"); + let state = undefined; + if(clicker_state_storage){ + state = JSON.parse(clicker_state_storage); + } + const clicker_model = new ClickerModel(state); + + const bus = clicker_model.bus; + bus.addEventListener("MILESTONE", (ev) => { + services.effect.add({ + type: "rainbow_man", + message: `Milestone reached! You can now buy ${ev.detail.unlock}`, + }); + }); + + bus.addEventListener("REWARD", (ev) => { + const reward = ev.detail; + const closeNotification = services.notification.add( + `Congrats you won a reward: "${reward.description}"`, + { + type: "success", + sticky: true, + buttons: [ + { + name: "Collect", + onClick: () => { + reward.apply(clicker_model); + closeNotification(); + services.action.doAction({ + type: "ir.actions.client", + tag: "awesome_clicker.client_action", + target: "new", + name: "Clicker Game" + }); + } + } + ] + } + ); + }); + + document.addEventListener("click", () => clicker_model.increment(1), true); + setInterval(() => { + clicker_model.tick(); + browser.localStorage.setItem("clicker_state", JSON.stringify(clicker_model)); + }, 10000); + + + return clicker_model; + } +}; + +registry.category("services").add("clickerService", clickerService); diff --git a/awesome_clicker/static/src/clicker_systray_item.js b/awesome_clicker/static/src/clicker_systray_item.js new file mode 100644 index 00000000000..92a909186e1 --- /dev/null +++ b/awesome_clicker/static/src/clicker_systray_item.js @@ -0,0 +1,58 @@ +import { registry } from "@web/core/registry"; +import { Component, useState } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { useClicker } from "./clicker_hook"; +import { ClickValue } from "./click_value/click_value"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; + + +class Clicker extends Component { + static template = "awesome_clicker.clicker_systray_item"; + static components = { ClickValue, Dropdown, DropdownItem }; + + setup(){ + this.clicker = useClicker(); + this.action = useService("action"); + } + + increment(){ + this.clicker.increment(9); + } + + openClientAction(){ + this.action.doAction({ + type: "ir.actions.client", + tag: "awesome_clicker.client_action", + target: "new", + name: "Clicker Game" + }); + } + + get numberTrees() { + let sum = 0; + for(const tree in this.clicker.trees){ + sum += this.clicker.trees[tree].purchased; + } + return sum; + } + + get numberFruits() { + let sum = 0; + for(const fruit in this.clicker.fruits){ + sum += this.clicker.fruits[fruit]; + } + return sum; + } + + get numberBots(){ + let sum = 0; + for(const bot in this.clicker.bots){ + sum += this.clicker.bots[bot].purchased; + } + return sum; + } + +} + +registry.category("systray").add("awesome_clicker.Clicker", { Component: Clicker }); diff --git a/awesome_clicker/static/src/clicker_systray_item.xml b/awesome_clicker/static/src/clicker_systray_item.xml new file mode 100644 index 00000000000..0c9b2c3ccdd --- /dev/null +++ b/awesome_clicker/static/src/clicker_systray_item.xml @@ -0,0 +1,40 @@ + + + +
+ + + + + + + + + + + x + + + + x + + + + x + + + + + +
+
+
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..90204257ea3 --- /dev/null +++ b/awesome_clicker/static/src/client_action/client_action.js @@ -0,0 +1,20 @@ +import { Component, useState } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { registry } from "@web/core/registry"; +import { useClicker } from "../clicker_hook"; +import { humanNumber } from "@web/core/utils/numbers"; +import { ClickValue } from "../click_value/click_value"; +import { Notebook } from "@web/core/notebook/notebook"; + + +export class ClientAction extends Component { + static template = "awesome_clicker.client_action"; + static components = { ClickValue, Notebook }; + + setup() { + this.clicker = useClicker(); + } + +} + +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..9d42c63498c --- /dev/null +++ b/awesome_clicker/static/src/client_action/client_action.xml @@ -0,0 +1,92 @@ + + + + +
+
+ Clicks: + + + + + + Level: [] + Total clicks per 10 seconds: [] +
+ + + + + +
+

Bots

+
+
+ x + ( + clicks/10 seconds) +
+
+ +
+
+
+
+

Power multiplier

+
+
+ x + +
+
+ +
+
+
+
+ +
+

Trees

+
+
+ + x (1x /30 seconds) + +
+
+ +
+
+

Fruits

+
+
+ + x s +
+
+
+
+
+ + + + + +
+
+
diff --git a/awesome_clicker/static/src/form_controller/form_controller_patch.js b/awesome_clicker/static/src/form_controller/form_controller_patch.js new file mode 100644 index 00000000000..5642b5816e1 --- /dev/null +++ b/awesome_clicker/static/src/form_controller/form_controller_patch.js @@ -0,0 +1,16 @@ +import { FormController } from "@web/views/form/form_controller"; +import { patch } from "@web/core/utils/patch"; +import { useClicker } from "../clicker_hook"; + + +const formControllerPatch = { + setup(){ + super.setup(...arguments); + if(Math.random() <= 0.01){ + const clicker = useClicker(); + clicker.getReward(); + } + } +}; + +patch(FormController.prototype, formControllerPatch); diff --git a/awesome_clicker/static/src/utils.js b/awesome_clicker/static/src/utils.js new file mode 100644 index 00000000000..b69b433b747 --- /dev/null +++ b/awesome_clicker/static/src/utils.js @@ -0,0 +1,7 @@ +export function choose(array){ + if(array.length === 1){ + return array[0]; + } + const index = Math.floor(Math.random() * (array.length)); + return array[index]; +} diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index 31406e8addb..0beaa1c2a35 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -25,6 +25,10 @@ 'web.assets_backend': [ 'awesome_dashboard/static/src/**/*', ], + 'awesome_dashboard.dashboard': [ + 'awesome_dashboard/static/src/dashboard/**/*' + ], + }, '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/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..10e0ed27fc1 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,89 @@ +/** @odoo-module **/ + +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 { PieChart } from "./pie_chart/pie_chart"; +import { items } from "./dashboard_items"; +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 components = { Layout, DashboardItem, PieChart }; + static template = "awesome_dashboard.AwesomeDashboard"; + + setup(){ + this.action = useService("action"); + this.statistics = useState(useService("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"); + } + + async openLeads(activity){ + this.action.doAction({ + type: 'ir.actions.act_window', + name: 'Leads', + target: 'current', + res_id: activity.res_id, + res_model: 'crm.lead', + views: [[false, 'list'], [false, 'form']] + }); + } + + openSettings(){ + 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.settings_dialog"; + static components = { Dialog, CheckBox }; + static props = ["items", "close", "disabledItems", "onUpdateConfiguration"]; + + setup(){ + this.items = useState(this.props.items.map((item) => { + return { + ...item, + enabled: !this.props.disabledItems.includes(item.id), + } + })); + } + + 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); + } + + done(){ + this.props.close(); + } + +} + +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..535010ff899 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.scss @@ -0,0 +1,7 @@ +.o_dashboard{ + background-color: grey; +} + +.green{ + color: green; +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..3f76897b222 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,41 @@ + + + + +
+ + + + +
+ +
+ + + + + + +
+
+
+ + + + Wich cards do you wish to see ? + + + + + + + + + + + +
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..957bc0a8ce9 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js @@ -0,0 +1,17 @@ +/** @odoo-module **/ + +import { Component, useState } from "@odoo/owl"; + + +export class DashboardItem extends Component { + static template = "awesome_dashboard.dashboard_item"; + + static props = { + size: {type: Number, optional: true}, + slots: {type: Object, optional: true}, + }; + + static defaultProps = { + size: 1, + }; +} 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..d801dfd621e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item/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..0862bad2362 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,66 @@ +import { NumberCard } from "./number_card/number_card"; +import { PieChartCard } from "./pie_chart_card/pie_chart_card"; +import { registry } from "@web/core/registry"; + + +const items = [ + { + id: "average_quantity", + description: "Average amount of t-shirt", + Component: NumberCard, + props: (data) => ({ + title: "Average amount of t-shirt by order this month", + value: data.average_quantity + }), + }, + { + id: "average_time", + description: "Average time", + Component: NumberCard, + 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 this month", + Component: NumberCard, + props: (data) => ({ + title: "Number of new orders this month", + value: data.nb_new_orders + }), + }, + { + id: "nb_cancelled_orders", + description: "Number of cancelled orders this month", + Component: NumberCard, + props: (data) => ({ + title: "Number of cancelled orders this month", + value: data.nb_cancelled_orders + }), + }, + { + id: "total_amount", + description: "Total amount of new orders this month", + Component: NumberCard, + props: (data) => ({ + title: "Total amount of new orders this month", + value: data.total_amount + }), + }, + { + id: "order_by_size", + description: "Shirt orders by size", + Component: PieChartCard, + size: 2, + props: (data) => ({ + title: "Shirt orders by size", + value: 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..4a64d289771 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.js @@ -0,0 +1,13 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + + +export class NumberCard extends Component{ + static template = "awesome_dashboard.number_card"; + + static props = { + title: {type: String, optional: true}, + value: {type: Number, optional: true}, + }; +} 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..34eed7a3101 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml @@ -0,0 +1,9 @@ + + + + +

+

+
+ +
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..d4e400fd26e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js @@ -0,0 +1,38 @@ +/** @odoo-module **/ + +import { Component, onWillStart, useRef, onMounted, onWillUnmount } from "@odoo/owl"; +import { loadJS } from "@web/core/assets"; + + +export class PieChart extends Component { + static template = "awesome_owl.pie_chart"; + + static props = { data: Object }; + + setup() { + this.canvasRef = useRef("canvas"); + onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js")); + onMounted(() => this.renderChart()); + onWillUnmount(() => this.chart.destroy()); + } + + renderChart(){ + if(this.chart){ + this.chart.destroy(); + } + const labels = Object.keys(this.props.data); + const data = Object.values(this.props.data); + this.chart = new Chart(this.canvasRef.el, { + type: "pie", + data: { + labels: labels, + datasets: [ + { + label: this.props.label, + data: data, + } + ] + } + }); + } +} diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml similarity index 56% rename from awesome_dashboard/static/src/dashboard.xml rename to awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml index 1a2ac9a2fed..48161459cbf 100644 --- a/awesome_dashboard/static/src/dashboard.xml +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml @@ -1,8 +1,8 @@ - - hello dashboard + + 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..b9a964abcc7 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js @@ -0,0 +1,38 @@ +/** @odoo-module **/ + +import { Component, useRef, onWillStart, onMounted, onWillUnmount } from "@odoo/owl"; +import { loadJS } from "@web/core/assets"; + + +export class PieChartCard extends Component{ + static template = "awesome_dashboard.pie_chart_card"; + + static props = { + title: {type: String, optional: true}, + value: {type: Object, optional: true}, + }; + + setup(){ + this.canvasRef = useRef("canvas"); + onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js")); + onMounted(() => this.renderChart()); + onWillUnmount(() => this.chart.destroy()); + } + + renderChart(){ + const labels = Object.keys(this.props.value); + const data = Object.values(this.props.value); + this.chart = new Chart(this.canvasRef.el, { + type: "pie", + data: { + labels: labels, + datasets: [ + { + label: this.props.label, + data: data, + } + ] + } + }); + } +} 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..f6d281df91e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml @@ -0,0 +1,9 @@ + + + + +

+ +
+ +
diff --git a/awesome_dashboard/static/src/dashboard/statistics.js b/awesome_dashboard/static/src/dashboard/statistics.js new file mode 100644 index 00000000000..06365dbe280 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/statistics.js @@ -0,0 +1,22 @@ +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; +import { reactive } from "@odoo/owl"; + + +export const statisticsService = { + start() { + const data = reactive({ isReady: false }); + + async function loadData(){ + const updates = await rpc("/awesome_dashboard/statistics"); + Object.assign(data, updates, {isReady: true}); + } + + setInterval(loadData, 5000); + loadData(); + + return data; + } +}; + +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..fd7186fe05a --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_action.js @@ -0,0 +1,16 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { LazyComponent } from "@web/core/assets"; +import { Component, xml } from "@odoo/owl"; + + +export class DashboardAction extends Component { + static components = { LazyComponent }; + static template = xml` + + `; + +} + +registry.category("actions").add("awesome_dashboard.dashboard", DashboardAction); diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..3260a1ee316 --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,20 @@ +/** @odoo-module **/ + +import { Component, useState } from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.card"; + + static props = { + title: {type: String}, + slots: {type: Object, optional: true}, + }; + + setup(){ + this.state = useState({ contentVisible: true }); + } + + toggleContent(){ + this.state.contentVisible ^= true; + } +} diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 00000000000..012d29e4a8c --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,16 @@ + + + + +
+
+
+ +
+ + +
+
+
+ +
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..c197a3abada --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,23 @@ +/** @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..5e1a84ab012 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,9 @@ + + + + +

Counter

+ +
+ +
\ No newline at end of file diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 657fb8b07bb..5bfbe920e96 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,7 +1,28 @@ /** @odoo-module **/ -import { Component } from "@odoo/owl"; +import { markup, Component, 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 = { TodoList, Counter, Card }; + + setup(){ + this.state = useState({ + card1: { title: "card 1", content: "content of card 1" }, + card2: { title: "card 2", content: "content of card 2" }, + sum: 2 + }); + + this.content_value_escaped = "
some content
"; + this.content_value = markup("
some content
"); + } + + incrementSum(){ + this.state.sum++; + } + } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..1a0ea03a584 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -4,6 +4,14 @@
hello world + + +
+ + + +

The sum is:

+
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..0c186817fdb --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.js @@ -0,0 +1,13 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class TodoItem extends Component{ + static template = "awesome_owl.todo_item"; + + static props = { + todo: {type: Object}, + toggleState: {type: Function}, + removeTodo: {type: Function}, + }; +} 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..dc2590106f6 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.xml @@ -0,0 +1,10 @@ + + + +
+ + . + +
+
+
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..5f40f6ffce2 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.js @@ -0,0 +1,40 @@ +/** @odoo-module**/ + +import { onMounted, useRef, useState, Component } from "@odoo/owl"; +import { TodoItem } from "./todo_item"; +import { useAutoFocus } from "../utils"; + +export class TodoList extends Component { + static template = "awesome_owl.todo_list"; + + static components = { TodoItem }; + + setup() { + this.todos = useState([ + { id: 3, description: "buy milk", isCompleted: false }, + { id: 4, description: "testt", isCompleted: true }, + ]); + this.state = useState({newTodo: ""}); + useAutoFocus("new_todo"); + } + + addTodo(ev) { + if(ev.keyCode === 13 && this.state.newTodo){ + this.todos.push({ + id: (this.todos.length > 0 ? ((Math.max(...this.todos.map(t => t.id))) + 1) : 0), + description: this.state.newTodo, + isCompleted: false + }); + this.state.newTodo = ""; + } + } + + toggleState(id){ + this.todos[this.todos.findIndex(todo => todo.id === id)].isCompleted ^= true; + } + + removeTodo(id){ + this.todos.splice(this.todos.findIndex(todo => todo.id === id), 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..6cc64afdb04 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.xml @@ -0,0 +1,15 @@ + + + + +
+ + +
+
+ +
diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..63773b05e5d --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,9 @@ +import { useEffect, useRef } from "@odoo/owl"; + +export function useAutoFocus(name){ + let ref = useRef(name); + useEffect( + (el) => el && el.focus(), + () => [ref.el] + ); +} diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..771ebf3f185 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,19 @@ +{ + 'name': 'Real Estate', + 'depends': [ + 'base', + ], + 'data': [ + 'views/res_users_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_property_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_menus.xml', + + 'security/security.xml', + 'security/ir.model.access.csv', + ], + 'category': 'Real Estate/Brokerage', + '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..f9194093b2b --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,105 @@ +from dateutil.relativedelta import relativedelta +from odoo import api, fields, models +from odoo.exceptions import ValidationError, UserError +from odoo.tools.float_utils import float_compare + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Real Estate" + _order = "id desc" + + name = fields.Char(required=True) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date(default=fields.Date.today()+relativedelta(months=3), copy=False) + expected_price = fields.Float(required=True) + selling_price = fields.Float(readonly=True, copy=False) + bedrooms = fields.Integer(default=2) + living_area = fields.Integer(string="Living Area (sqm)") + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer() + 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, + string='Status', + ) + property_type_id = fields.Many2one('estate.property.type', string='Property Type') + partner_id = fields.Many2one('res.partner', string='Buyer') + user_id = fields.Many2one( + 'res.users', string='Salesman', + default=lambda self: self.env.user, + copy=False, + ) + tag_ids = fields.Many2many('estate.property.tag') + offer_ids = fields.One2many('estate.property.offer', 'property_id', string='Offers') + total_area = fields.Integer(compute="_compute_total_area") + best_price = fields.Float(compute="_compute_best_price") + + _sql_constraints = [ + ('check_expected_price', 'CHECK(expected_price > 0)', 'The expected price should be greater than 0.'), + ('check_selling_price', 'CHECK(selling_price >= 0)', 'The selling price should be positive.'), + ] + + @api.depends('living_area', 'garden_area') + def _compute_total_area(self): + for property in self: + property.total_area = property.living_area + property.garden_area + + @api.depends('offer_ids.price') + def _compute_best_price(self): + for property in self: + property.best_price = max(property.offer_ids.mapped('price'), default=0.0) + + @api.onchange('garden') + def _onchange_garden(self): + if self.garden: + self.garden_area = 10 + self.garden_orientation = 'north' + else: + self.garden_area = 0 + self.garden_orientation = '' + + @api.constrains('selling_price') + def _check_selling_price(self): + for property in self: + if float_compare(property.selling_price, float(property.expected_price * 90 / 100), 2) == -1: + raise ValidationError("The selling price must be at least 90% of your expected price.") + + def action_set_status_sold(self): + for property in self: + if property.state == 'cancelled': + raise UserError("Cancelled properties cannot be sold.") + + property.state = 'sold' + return True + + def action_set_status_cancelled(self): + for property in self: + if property.state == 'sold': + raise UserError("Sold properties cannot be cancelled.") + + property.state = 'cancelled' + return True + + @api.ondelete(at_uninstall=False) + def _unlink_except_property_new_cancelled(self): + for property in self: + if not property.state in ('new', 'cancelled'): + raise UserError("Only new and cancelled properties can be deleted.") + + return super().unlink() diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..9c2d11a93cd --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,68 @@ +from datetime import datetime +from dateutil.relativedelta import relativedelta +from odoo.exceptions import UserError +from odoo import api, fields, models + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Real Estate Property Offer" + _order = "price desc" + + price = fields.Float() + status = fields.Selection( + selection=[('accepted', 'Accepted'), ('refused', 'Refused')], + copy=False, + ) + validity = fields.Integer(default=7, string="Validity (days)") + partner_id = fields.Many2one('res.partner', required=True) + property_id = fields.Many2one('estate.property', required=True) + date_deadline = fields.Date( + compute='_compute_date_deadline', + inverse='_inverse_date_deadline', + string="Deadline", + ) + property_type_id = fields.Many2one(related='property_id.property_type_id', store=True) + + _sql_constraints = [ + ('check_price', 'CHECK(price > 0)', 'The price of an offer must be strictly positive.'), + ] + + @api.depends('validity', 'create_date') + def _compute_date_deadline(self): + for offer in self: + offer.date_deadline = ( + offer.create_date + relativedelta(days=offer.validity) + if type(offer.create_date) is datetime + else fields.Date.context_today(self) + relativedelta(days=offer.validity) + ) + + def _inverse_date_deadline(self): + for offer in self: + offer.validity = (offer.date_deadline - offer.create_date.date()).days + + def action_accept_offer(self): + if any(offer.status == 'accepted' for offer in self.property_id.offer_ids): + raise UserError('An offer is already accepted.') + + self.status = 'accepted' + self.property_id.partner_id = self.partner_id + self.property_id.selling_price = self.price + self.property_id.state = 'offer_accepted' + + def action_refuse_offer(self): + self.status = 'refused' + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + max_offer = max((offer.price for offer in self.env['estate.property'].browse(vals['property_id']).offer_ids), default=0.0) + if max_offer > vals['price']: + raise UserError(f"The offer price must be at least {max_offer:,.2f}") + + offers = super().create(vals_list) + + for vals in vals_list: + self.env['estate.property'].browse(vals['property_id']).state = 'offer_received' + + return offers diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..3ddb984e6d3 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,14 @@ +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Real Estate Tag" + _order = "name" + + name = fields.Char(required=True) + color = fields.Integer() + + _sql_constraints = [ + ('check_name', 'UNIQUE(name)', 'The 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..be12336f98a --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,23 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Real Estate Type" + _order = "sequence, name" + + name = fields.Char(required=True) + sequence = fields.Integer('Sequence', default=1) + property_ids = fields.One2many('estate.property', 'property_type_id', string="Properties") + offer_ids = fields.One2many('estate.property.offer', 'property_type_id', string="Offers") + offer_count = fields.Integer(compute="_compute_offer_count") + + _sql_constraints = [ + ('check_name', 'UNIQUE(name)', 'The name must be unique.'), + ] + + @api.depends('offer_ids') + def _compute_offer_count(self): + for type in self: + type.offer_count = len(type.offer_ids) diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..1713cb19389 --- /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', + domain="[('state', 'in', ('new', 'offer_received'))]", + string="Properties", + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..b93e27a7d40 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property_model_manager,access_estate_property_model,model_estate_property,estate_group_manager,1,1,1,0 +access_estate_property_model_agent,access_estate_property_model,model_estate_property,estate_group_user,1,1,1,0 +access_estate_property_type_model_manager,access_estate_property_type_model,model_estate_property_type,estate_group_manager,1,1,1,1 +access_estate_property_type_model_agent,access_estate_property_type_model,model_estate_property_type,estate_group_user,1,0,0,0 +access_estate_property_tag_model_manager,access_estate_property_tag_model,model_estate_property_tag,estate_group_manager,1,1,1,1 +access_estate_property_tag_model_agent,access_estate_property_tag_model,model_estate_property_tag,estate_group_user,1,0,0,0 +access_estate_property_offer_model_manager,access_estate_property_offer_model,model_estate_property_offer,estate_group_manager,1,1,1,1 +access_estate_property_offer_model_agent,access_estate_property_offer_model,model_estate_property_offer,estate_group_user,1,1,1,1 diff --git a/estate/security/ir.model.access.old.csv b/estate/security/ir.model.access.old.csv new file mode 100644 index 00000000000..4073dc03078 --- /dev/null +++ b/estate/security/ir.model.access.old.csv @@ -0,0 +1,5 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property_model,access_estate_property_model,model_estate_property,base.group_user,1,1,1,1 +access_estate_property_type_model,access_estate_property_type_model,model_estate_property_type,base.group_user,1,1,1,1 +access_estate_property_tag_model,access_estate_property_tag_model,model_estate_property_tag,base.group_user,1,1,1,1 +access_estate_property_offer_model,access_estate_property_offer_model,model_estate_property_offer,base.group_user,1,1,1,1 diff --git a/estate/security/security.xml b/estate/security/security.xml new file mode 100644 index 00000000000..0595c37eae7 --- /dev/null +++ b/estate/security/security.xml @@ -0,0 +1,32 @@ + + + + + + + + + Agent + + The user can manage the properties under his care + + + + Manager + + + The user can do agent things and settings of the real estate App + + + + Agent can only see modify their properties or the unassigned ones + + + + [ + '|', ('user_id', '=', user.id), + ('user_id', '=', False) + ] + + + diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..2e13d034d59 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..fcd15270e8f --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,34 @@ + + + estate.property.offer.form + estate.property.offer + +
+ + + + + + + + + +
+
+
+ + + estate.property.offer.list + estate.property.offer + + + + + + + + + +

+ +

+ + + + + + + + + + + +
+ +
+
+ + + estate.property.type.list + estate.property.type + + + + + + + +
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..53b9fe54154 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,154 @@ + + + estate.property.form + estate.property + +
+
+ +
+ + + +

+ +

+
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + estate.property.list + estate.property + + + + + + + + + + + + + + + estate.property.kanban + estate.property + + + + + +
+ +
+ +
+ +
+
+ +
+ +
+
+
+
+
+
+ + + estate.property.view.search + estate.property + + + + + + + + + + + + + + + + Properties + estate.property + list,form,kanban + {'search_default_available': True, 'search_default_current': True} + + + + Property Types + estate.property.type + list,form + + + + Property Tags + estate.property.tag + list,form + + + + Offers + estate.property.offer + list,form + [('event_id', '=', active_id)] + +
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..97233527f62 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,14 @@ + + + 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..9a7e03eded3 --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..f5ceb4a910c --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,7 @@ +{ + 'name': 'Estate Account', + '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..39c30490a34 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,26 @@ +from odoo import Command, models + + +class EstateProperty(models.Model): + _inherit = "estate.property" + + def action_set_status_sold(self): + self.env['account.move'].create( + { + 'partner_id': self.partner_id.id, + 'move_type': 'out_invoice', + 'invoice_line_ids': [ + Command.create({ + 'name': '6 Percent of selling price', + 'quantity': 0.06, + 'price_unit': self.selling_price + }), + Command.create({ + 'name': 'Administrative fees', + 'quantity': 1.0, + 'price_unit': 100.0, + }) + ], + } + ) + return super().action_set_status_sold()