diff --git a/.gitignore b/.gitignore
index b6e47617de1..d7ab9e00691 100644
--- a/.gitignore
+++ b/.gitignore
@@ -127,3 +127,6 @@ dmypy.json
# Pyre type checker
.pyre/
+
+# Ruff configuration
+ruff.toml
diff --git a/awesome_clicker/static/src/clicker_action/clicker_client_action.js b/awesome_clicker/static/src/clicker_action/clicker_client_action.js
new file mode 100644
index 00000000000..42c8f3b52b0
--- /dev/null
+++ b/awesome_clicker/static/src/clicker_action/clicker_client_action.js
@@ -0,0 +1,15 @@
+import { Component, useState } from "@odoo/owl";
+import { registry } from "@web/core/registry";
+import { useClicker } from "../clicker_hook";
+import { ClickerValue } from "../clicker_value/clicker_value";
+
+class ClickerClientAction extends Component {
+ static template = "awesome_clicker.ClickerClientAction";
+ static components = { ClickerValue };
+
+ setup() {
+ this.clickerService = useClicker();
+ }
+}
+
+registry.category("actions").add("awesome_clicker.clicker_client_action", ClickerClientAction);
diff --git a/awesome_clicker/static/src/clicker_action/clicker_client_action.xml b/awesome_clicker/static/src/clicker_action/clicker_client_action.xml
new file mode 100644
index 00000000000..401001672bf
--- /dev/null
+++ b/awesome_clicker/static/src/clicker_action/clicker_client_action.xml
@@ -0,0 +1,35 @@
+
+
+
+
+ Clicks:
+
+
+ Bots
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_clicker/static/src/clicker_hook.js b/awesome_clicker/static/src/clicker_hook.js
new file mode 100644
index 00000000000..64ec02c382a
--- /dev/null
+++ b/awesome_clicker/static/src/clicker_hook.js
@@ -0,0 +1,6 @@
+import { useState } from "@odoo/owl";
+import { useService } from "@web/core/utils/hooks";
+
+export function useClicker() {
+ return useState(useService("awesome_clicker.count"));
+}
diff --git a/awesome_clicker/static/src/clicker_service.js b/awesome_clicker/static/src/clicker_service.js
new file mode 100644
index 00000000000..912c4edb122
--- /dev/null
+++ b/awesome_clicker/static/src/clicker_service.js
@@ -0,0 +1,27 @@
+import { registry } from "@web/core/registry";
+import { ClickerModel } from "./model/clicker_model";
+
+export const getCount = {
+ dependencies: ["effect"],
+ start(env, services) {
+ const clicker = new ClickerModel();
+
+ setInterval(() => {
+ clicker.tick();
+ }, 10000)
+
+ const bus = clicker.bus
+ bus.addEventListener("MILESTONE", (ev) => {
+ services.effect.add({
+ message: `Milestone reached! You can now buy ${ev.detail}!`,
+ type: "rainbow_man",
+ })
+ })
+
+ document.addEventListener("click", () => clicker.increment(1), true)
+
+ return clicker;
+ }
+}
+
+registry.category("services").add("awesome_clicker.count", getCount);
diff --git a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js
new file mode 100644
index 00000000000..8f92c121600
--- /dev/null
+++ b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js
@@ -0,0 +1,30 @@
+import { registry } from "@web/core/registry";
+import { Component } from "@odoo/owl";
+import { useService } from "@web/core/utils/hooks";
+import { useClicker } from "../clicker_hook";
+import { ClickerValue } from "../clicker_value/clicker_value";
+
+export class ClickerSystray extends Component {
+ static template = "awesome_clicker.ClickerSystray";
+ static components = { ClickerValue };
+
+ setup() {
+ this.counterService = useClicker();
+ this.action = useService("action");
+ }
+
+ openClientAction() {
+ this.action.doAction({
+ type: "ir.actions.client",
+ tag: "awesome_clicker.clicker_client_action",
+ target: "new",
+ name: "Clicker Game",
+ })
+ }
+}
+
+export const systrayItem = {
+ Component: ClickerSystray,
+};
+
+registry.category("systray").add("awesome_clicker.ClickerSystray", systrayItem, { sequence: 1000 });
diff --git a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml
new file mode 100644
index 00000000000..591f368ba48
--- /dev/null
+++ b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+ Clicks:
+
+
+
+
+
+
\ No newline at end of file
diff --git a/awesome_clicker/static/src/clicker_value/clicker_value.js b/awesome_clicker/static/src/clicker_value/clicker_value.js
new file mode 100644
index 00000000000..78ff51154e9
--- /dev/null
+++ b/awesome_clicker/static/src/clicker_value/clicker_value.js
@@ -0,0 +1,17 @@
+import { Component } from "@odoo/owl";
+import { useClicker } from "../clicker_hook";
+import { humanNumber } from "@web/core/utils/numbers";
+
+export class ClickerValue extends Component {
+ static template = "awesome_clicker.ClickerValue";
+
+ setup() {
+ this.clicker = useClicker();
+ }
+
+ get getClicks() {
+ return humanNumber(this.clicker.clicks, {
+ decimals: 1,
+ });
+ }
+}
diff --git a/awesome_clicker/static/src/clicker_value/clicker_value.xml b/awesome_clicker/static/src/clicker_value/clicker_value.xml
new file mode 100644
index 00000000000..92a65c39c0f
--- /dev/null
+++ b/awesome_clicker/static/src/clicker_value/clicker_value.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/awesome_clicker/static/src/model/clicker_model.js b/awesome_clicker/static/src/model/clicker_model.js
new file mode 100644
index 00000000000..05ae006a6e5
--- /dev/null
+++ b/awesome_clicker/static/src/model/clicker_model.js
@@ -0,0 +1,61 @@
+import { Reactive } from "@web/core/utils/reactive";
+import { EventBus } from "@odoo/owl";
+
+export class ClickerModel extends Reactive {
+
+ constructor() {
+ super();
+ this.clicks = 0;
+ this.level = 0;
+ this.bus = new EventBus();
+ this.bots = {
+ clickBot: {
+ price: 1000,
+ level: 1,
+ increment: 10,
+ purchased: 0,
+ },
+ bigBot: {
+ price: 5000,
+ level: 2,
+ increment: 100,
+ purchased: 0,
+ },
+ }
+ }
+
+ increment(value) {
+ this.clicks += value;
+
+ if (this.milestones[this.level] && this.clicks >= this.milestones[this.level].clicks) {
+ this.bus.trigger("MILESTONE", this.milestones[this.level].unlock);
+ this.level++;
+ }
+ }
+
+ buyBot(name) {
+ if (!Object.keys(this.bots).includes(name)) {
+ throw new Error(`Invalid bot name ${name}`)
+ }
+
+ if (this.clicks < this.bots[name].price) {
+ return false;
+ }
+
+ this.clicks -= this.bots[name].price;
+ this.bots[name].purchased += 1;
+ }
+
+ tick() {
+ for (const bot in this.bots) {
+ this.increment(this.bots[bot].purchased * this.bots[bot].increment);
+ }
+ }
+
+ get milestones() {
+ return [
+ { clicks: 1000, unlock: "clickbots" },
+ { clicks: 5000, unlock: "bigbots" },
+ ];
+ }
+}
diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py
index 31406e8addb..e9c0c7558b2 100644
--- a/awesome_dashboard/__manifest__.py
+++ b/awesome_dashboard/__manifest__.py
@@ -16,7 +16,7 @@
'version': '0.1',
'application': True,
'installable': True,
- 'depends': ['base', 'web', 'mail', 'crm'],
+ 'depends': ['base', 'web', 'mail', 'crm', 'sale'],
'data': [
'views/views.xml',
@@ -24,7 +24,11 @@
'assets': {
'web.assets_backend': [
'awesome_dashboard/static/src/**/*',
+ ('remove', 'awesome_dashboard/static/src/dashboard/**/*'),
+ ],
+ 'awesome_dashboard.dashboard': [
+ 'awesome_dashboard/static/src/dashboard/**/*',
],
},
- 'license': 'AGPL-3'
+ 'license': 'AGPL-3',
}
diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js
deleted file mode 100644
index 637fa4bb972..00000000000
--- a/awesome_dashboard/static/src/dashboard.js
+++ /dev/null
@@ -1,10 +0,0 @@
-/** @odoo-module **/
-
-import { Component } from "@odoo/owl";
-import { registry } from "@web/core/registry";
-
-class AwesomeDashboard extends Component {
- static template = "awesome_dashboard.AwesomeDashboard";
-}
-
-registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboard);
diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml
deleted file mode 100644
index 1a2ac9a2fed..00000000000
--- a/awesome_dashboard/static/src/dashboard.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
- hello dashboard
-
-
-
diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js
new file mode 100644
index 00000000000..e7d8c7f633a
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard.js
@@ -0,0 +1,90 @@
+/** @odoo-module **/
+
+import { _t } from "@web/core/l10n/translation";
+import { Component, useState } from "@odoo/owl";
+import { registry } from "@web/core/registry";
+import { Layout } from "@web/search/layout";
+import { PieChart } from "./pie_chart/pie_chart";
+import { DashboardItem } from "./dashboard_item";
+import { useService } from "@web/core/utils/hooks";
+import { Dialog } from "@web/core/dialog/dialog";
+import { CheckBox } from "@web/core/checkbox/checkbox";
+import { browser } from "@web/core/browser/browser";
+
+class AwesomeDashboard extends Component {
+ static template = "awesome_dashboard.AwesomeDashboard";
+ static components = { DashboardItem, Layout, PieChart };
+
+ setup() {
+ this.action = useService("action");
+ this.stats = useState(useService("awesome_dashboard.statistics"));
+ this.items = registry.category("awesome_dashboard").getAll();
+ this.dialog = useService("dialog");
+ this.state = useState({
+ disabledItems: browser.localStorage.getItem("disabledDashboardItems")?.split(",") || []
+ })
+ }
+
+ openCustomers() {
+ this.action.doAction("base.action_partner_form");
+ }
+
+ openLeads() {
+ this.action.doAction({
+ type: 'ir.actions.act_window',
+ name: _t('All leads'),
+ res_model: 'crm.lead',
+ views: [
+ [false, 'list'],
+ [false, 'form'],
+ ],
+ });
+ }
+
+ openConfiguration() {
+ this.dialog.add(ConfigurationDialog, {
+ items: this.items,
+ disabledItems: this.state.disabledItems,
+ onUpdateConfiguration: this.updateConfiguration.bind(this),
+ })
+ }
+
+ updateConfiguration(newDisabledItems) {
+ this.state.disabledItems = newDisabledItems;
+ }
+}
+
+class ConfigurationDialog extends Component {
+ static template = "awesome_dashboard.ConfigurationDialog";
+ static components = { Dialog, CheckBox };
+ static props = ["close", "items", "disabledItems", "onUpdateConfiguration"];
+
+ setup() {
+ this.items = useState(this.props.items.map((item) => {
+ return {
+ ...item,
+ enabled: !this.props.disabledItems.includes(item.id),
+ }
+ }));
+ }
+
+ done() {
+ this.props.close();
+ }
+
+ onChange(checked, changedItem) {
+ changedItem.enabled = checked;
+ const newDisabledItems = Object.values(this.items).filter(
+ (item) => !item.enabled
+ ).map((item) => item.id)
+
+ browser.localStorage.setItem(
+ "disabledDashboardItems",
+ newDisabledItems
+ );
+
+ this.props.onUpdateConfiguration(newDisabledItems);
+ }
+}
+
+registry.category("lazy_components").add("AwesomeDashboard", AwesomeDashboard);
diff --git a/awesome_dashboard/static/src/dashboard/dashboard.scss b/awesome_dashboard/static/src/dashboard/dashboard.scss
new file mode 100644
index 00000000000..32862ec0d82
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard.scss
@@ -0,0 +1,3 @@
+.o_dashboard {
+ background-color: gray;
+}
diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml
new file mode 100644
index 00000000000..e50eb5e77d0
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item.js b/awesome_dashboard/static/src/dashboard/dashboard_item.js
new file mode 100644
index 00000000000..58c2fc06fab
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_item.js
@@ -0,0 +1,20 @@
+import { Component } from "@odoo/owl";
+
+export class DashboardItem extends Component {
+ static template = "awesome_dashboard.DashboardItem";
+ static props = {
+ slots: {
+ type: Object,
+ },
+ size: {
+ type: Number,
+ optional: true,
+ },
+ };
+
+ setup() {
+ if (!this.props.size) {
+ this.props.size = 1;
+ }
+ }
+}
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item.xml b/awesome_dashboard/static/src/dashboard/dashboard_item.xml
new file mode 100644
index 00000000000..76a58cfa722
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_item.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_items.js b/awesome_dashboard/static/src/dashboard/dashboard_items.js
new file mode 100644
index 00000000000..64a442de30e
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js
@@ -0,0 +1,67 @@
+import { NumberCard } from "./number_card/number_card";
+import { PieChartCard } from "./pie_chart_card/pie_chart_card";
+import { registry } from "@web/core/registry";
+import { _t } from "@web/core/l10n/translation";
+
+const items = [
+ {
+ id: "average_quantity",
+ description: _t("Average amount of t-shirt"),
+ Component: NumberCard,
+ props: (data) => ({
+ title: _t("Average amount of t-shirt by order this month"),
+ value: data.average_quantity,
+ }),
+ },
+ {
+ id: "average_time",
+ description: _t("Average time for an order"),
+ Component: NumberCard,
+ size: 2,
+ props: (data) => ({
+ title: _t("Average time for an order to go from 'new' to 'sent' or 'cancelled'"),
+ value: data.average_time,
+ }),
+ },
+ {
+ id: "number_new_orders",
+ description: _t("New orders this month"),
+ Component: NumberCard,
+ props: (data) => ({
+ title: _t("Number of new orders this month"),
+ value: data.nb_new_orders,
+ }),
+ },
+ {
+ id: "nb_cancelled_orders",
+ description: _t("Cancelled orders this month"),
+ Component: NumberCard,
+ props: (data) => ({
+ title: _t("Number of cancelled orders this month"),
+ value: data.nb_cancelled_orders,
+ }),
+ },
+ {
+ id: "total_amount",
+ description: _t("Amount of orders this month"),
+ Component: NumberCard,
+ props: (data) => ({
+ title: _t("Total amount of new orders this month"),
+ value: data.total_amount,
+ }),
+ },
+ {
+ id: "pie_chart",
+ description: _t("Shirt orders by size"),
+ Component: PieChartCard,
+ size: 2,
+ props: (data) => ({
+ title: _t("Shirt orders by size"),
+ data: data.orders_by_size,
+ }),
+ },
+]
+
+items.forEach(item => {
+ registry.category("awesome_dashboard").add(item.id, item);
+})
diff --git a/awesome_dashboard/static/src/dashboard/number_card/number_card.js b/awesome_dashboard/static/src/dashboard/number_card/number_card.js
new file mode 100644
index 00000000000..36706b348d2
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.js
@@ -0,0 +1,13 @@
+import { Component } from "@odoo/owl";
+
+export class NumberCard extends Component {
+ static template = "awesome_dashboard.NumberCard";
+ static props = {
+ title: {
+ type: String,
+ },
+ value: {
+ type: Number,
+ },
+ }
+}
diff --git a/awesome_dashboard/static/src/dashboard/number_card/number_card.xml b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml
new file mode 100644
index 00000000000..3b67b1a3a1c
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js
new file mode 100644
index 00000000000..845c9fd780f
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js
@@ -0,0 +1,64 @@
+import { Component, useRef, onMounted, onWillStart, onWillUnmount, onWillUpdateProps } from "@odoo/owl";
+import { loadJS } from "@web/core/assets";
+import { useService } from "@web/core/utils/hooks";
+import { _t } from "@web/core/l10n/translation";
+
+export class PieChart extends Component {
+ static template = "awesome_dashboard.PieChart";
+ static props = {
+ 'data': {
+ type: Object,
+ },
+ };
+
+ setup() {
+ onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js"));
+ this.chartRef = useRef("chart");
+ onMounted(() => {
+ this.renderChart();
+ });
+ onWillUpdateProps((props) => {
+ this.chart.data.datasets[0].data = Object.values(props.data);
+ this.chart.update();
+ });
+ onWillUnmount(() => {
+ this.chart.destroy();
+ });
+ this.action = useService("action");
+ }
+
+ renderChart() {
+ const labels = Object.keys(this.props.data);
+ this.chart = new Chart(this.chartRef.el, {
+ type: "pie",
+ data: {
+ labels: labels,
+ datasets: [{
+ data: Object.values(this.props.data),
+ }],
+ },
+ options: {
+ onClick: (ev, elements) => {
+ if (elements.length > 0) {
+ const index = elements[0].index;
+ const clickedLabel = labels[index];
+ this.openOrderListView(clickedLabel)
+ }
+ }
+ }
+ });
+ };
+
+ openOrderListView(size) {
+ this.action.doAction({
+ type: "ir.actions.act_window",
+ name: _t(`Orders with size ${size}`),
+ res_model: "sale.order",
+ views: [
+ [false, 'list'],
+ [false, 'form'],
+ ],
+ domain: [["name", "=", size]],
+ });
+ }
+}
diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml
new file mode 100644
index 00000000000..f0ccbab4d94
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js
new file mode 100644
index 00000000000..495ed14cd85
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js
@@ -0,0 +1,15 @@
+import { Component } from "@odoo/owl";
+import { PieChart } from "../pie_chart/pie_chart";
+
+export class PieChartCard extends Component {
+ static template = "awesome_dashboard.PieChartCard";
+ static components = { PieChart };
+ static props = {
+ title: {
+ type: String,
+ },
+ data: {
+ type: Object,
+ }
+ }
+}
\ No newline at end of file
diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml
new file mode 100644
index 00000000000..dd055b535c0
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/awesome_dashboard/static/src/dashboard/statistics_service.js b/awesome_dashboard/static/src/dashboard/statistics_service.js
new file mode 100644
index 00000000000..3b9afc12213
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/statistics_service.js
@@ -0,0 +1,21 @@
+import { registry } from "@web/core/registry";
+import { reactive } from "@odoo/owl";
+import { rpc } from "@web/core/network/rpc";
+
+export const loadStatistics = {
+ start() {
+ const data = reactive({ isReady: false })
+
+ async function loadData() {
+ const update = await rpc("/awesome_dashboard/statistics");
+ Object.assign(data, update, { isReady: true })
+ }
+
+ setInterval(loadData, 10*60*1000);
+ loadData();
+
+ return data
+ },
+};
+
+registry.category("services").add("awesome_dashboard.statistics", loadStatistics);
\ No newline at end of file
diff --git a/awesome_dashboard/static/src/dashboard_loader.js b/awesome_dashboard/static/src/dashboard_loader.js
new file mode 100644
index 00000000000..6403253b181
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard_loader.js
@@ -0,0 +1,14 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { Component, xml } from "@odoo/owl";
+import { LazyComponent } from "@web/core/assets";
+
+export class DashboardLoader extends Component {
+ static components = { LazyComponent };
+ static template = xml`
+
+ `;
+}
+
+registry.category("actions").add("awesome_dashboard.dashboard", DashboardLoader);
diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js
new file mode 100644
index 00000000000..e2f53d179be
--- /dev/null
+++ b/awesome_owl/static/src/card/card.js
@@ -0,0 +1,19 @@
+/** @odoo-module **/
+
+import { Component, useState } from "@odoo/owl";
+
+export class Card extends Component {
+ static template = "awesome_owl.card";
+ static props = {
+ title: String,
+ slots: Object,
+ };
+
+ setup() {
+ this.state = useState({ isOpen: true });
+ }
+
+ toggle() {
+ this.state.isOpen = !this.state.isOpen;
+ }
+}
diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml
new file mode 100644
index 00000000000..eaa30d952f0
--- /dev/null
+++ b/awesome_owl/static/src/card/card.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js
new file mode 100644
index 00000000000..bcf0f1b7944
--- /dev/null
+++ b/awesome_owl/static/src/counter/counter.js
@@ -0,0 +1,21 @@
+/** @odoo-module **/
+
+import { Component, useState } from "@odoo/owl";
+
+export class Counter extends Component {
+ static template = "awesome_owl.counter";
+ static props = {
+ onChange: { type: Function, optional: true }
+ };
+
+ setup() {
+ this.state = useState({ value:0 });
+ }
+
+ increment() {
+ this.state.value++;
+ if (this.props.onChange) {
+ this.props.onChange();
+ }
+ }
+}
diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml
new file mode 100644
index 00000000000..a6500fd5b6f
--- /dev/null
+++ b/awesome_owl/static/src/counter/counter.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+ Counter:
+
+
+
+
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js
index 657fb8b07bb..c564a4496c8 100644
--- a/awesome_owl/static/src/playground.js
+++ b/awesome_owl/static/src/playground.js
@@ -1,7 +1,21 @@
/** @odoo-module **/
-import { Component } from "@odoo/owl";
+import { Component, markup, useState } from "@odoo/owl";
+import { Counter } from "./counter/counter";
+import { Card } from "./card/card";
+import { TodoList } from "./todo/todo_list";
export class Playground extends Component {
static template = "awesome_owl.playground";
+ static components = { Card, Counter, TodoList };
+
+ setup() {
+ this.content1 = "
some content
";
+ this.content2 = markup("some content
");
+ this.sum = useState({ value: 2 });
+ }
+
+ incrementSum() {
+ this.sum.value++;
+ }
}
diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml
index 4fb905d59f9..3094c62f0b3 100644
--- a/awesome_owl/static/src/playground.xml
+++ b/awesome_owl/static/src/playground.xml
@@ -4,7 +4,20 @@
hello world
+
+
+
The sum is:
+
+
+
+ text 1
+
+
+
+
+
+
+
-
diff --git a/awesome_owl/static/src/todo/todo.js b/awesome_owl/static/src/todo/todo.js
new file mode 100644
index 00000000000..1b428da22cb
--- /dev/null
+++ b/awesome_owl/static/src/todo/todo.js
@@ -0,0 +1,14 @@
+export class Todo {
+ static nextId = 1;
+
+ constructor(description) {
+ this.id = Todo.nextId;
+ Todo.nextId++;
+ this.description = description;
+ this.isCompleted = false;
+ }
+
+ toggle() {
+ this.isCompleted = !this.isCompleted;
+ }
+}
diff --git a/awesome_owl/static/src/todo/todo_item.js b/awesome_owl/static/src/todo/todo_item.js
new file mode 100644
index 00000000000..760e124e129
--- /dev/null
+++ b/awesome_owl/static/src/todo/todo_item.js
@@ -0,0 +1,22 @@
+/** @odoo-module **/
+
+import { Component, useState } from "@odoo/owl";
+
+export class TodoItem extends Component {
+ static template = "awesome_owl.todoitem";
+ static props = {
+ todo: {
+ type: Object, shape: {
+ id: Number,
+ description: String,
+ isCompleted: Boolean,
+ }},
+ removeTodo: {
+ type: Function,
+ },
+ };
+
+ remove() {
+ this.props.removeTodo(this.props.todo.id);
+ }
+}
diff --git a/awesome_owl/static/src/todo/todo_item.xml b/awesome_owl/static/src/todo/todo_item.xml
new file mode 100644
index 00000000000..8192a064464
--- /dev/null
+++ b/awesome_owl/static/src/todo/todo_item.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/todo/todo_list.js b/awesome_owl/static/src/todo/todo_list.js
new file mode 100644
index 00000000000..cb4ff03f992
--- /dev/null
+++ b/awesome_owl/static/src/todo/todo_list.js
@@ -0,0 +1,31 @@
+/** @odoo-module **/
+
+import { Component, useState, useRef, onMounted } from "@odoo/owl";
+import { TodoItem } from "./todo_item";
+import { Todo } from "./todo";
+import { useAutoFocus } from "../utils";
+
+export class TodoList extends Component {
+ static template = "awesome_owl.todolist";
+ static components = { TodoItem };
+
+ setup() {
+ this.todos = useState([]);
+ useAutoFocus("input");
+ }
+
+ addTodo(ev) {
+ if (ev.keyCode === 13 && ev.target.value !== '') {
+ this.todos.push(new Todo(ev.target.value))
+ ev.target.value='';
+ }
+ }
+
+ removeTodo(id) {
+ const index = this.todos.findIndex((elem) => elem.id === id);
+
+ if (index >= 0) {
+ this.todos.splice(index, 1);
+ }
+ }
+}
diff --git a/awesome_owl/static/src/todo/todo_list.xml b/awesome_owl/static/src/todo/todo_list.xml
new file mode 100644
index 00000000000..f2598a83dd7
--- /dev/null
+++ b/awesome_owl/static/src/todo/todo_list.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js
new file mode 100644
index 00000000000..c68a4a5028e
--- /dev/null
+++ b/awesome_owl/static/src/utils.js
@@ -0,0 +1,8 @@
+import { useRef, onMounted } from "@odoo/owl"
+
+export function useAutoFocus(refName) {
+ const ref = useRef(refName)
+ onMounted(() => {
+ ref.el.focus()
+ })
+}
diff --git a/awesome_owl/views/templates.xml b/awesome_owl/views/templates.xml
index aa54c1a7241..3df6b44bd5b 100644
--- a/awesome_owl/views/templates.xml
+++ b/awesome_owl/views/templates.xml
@@ -5,6 +5,7 @@
+
diff --git a/estate/__init__.py b/estate/__init__.py
new file mode 100644
index 00000000000..0650744f6bc
--- /dev/null
+++ b/estate/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/estate/__manifest__.py b/estate/__manifest__.py
new file mode 100644
index 00000000000..37d98fa8b7f
--- /dev/null
+++ b/estate/__manifest__.py
@@ -0,0 +1,16 @@
+{
+ 'name': "Real estate",
+ 'version': '1.0',
+ 'depends': ['base'],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'views/estate_property_views.xml',
+ 'views/estate_property_offer_views.xml',
+ 'views/estate_property_type_views.xml',
+ 'views/estate_property_tag_views.xml',
+ 'views/res_users_views.xml',
+ 'views/estate_menus.xml',
+ ],
+ 'application': True,
+ 'license': 'LGPL-3',
+}
diff --git a/estate/models/__init__.py b/estate/models/__init__.py
new file mode 100644
index 00000000000..9a2189b6382
--- /dev/null
+++ b/estate/models/__init__.py
@@ -0,0 +1,5 @@
+from . import estate_property
+from . import estate_property_type
+from . import estate_property_tag
+from . import estate_property_offer
+from . import res_users
diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py
new file mode 100644
index 00000000000..238f1a81bf8
--- /dev/null
+++ b/estate/models/estate_property.py
@@ -0,0 +1,97 @@
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+from odoo.tools.float_utils import float_compare, float_is_zero
+
+
+class Property(models.Model):
+ _name = 'estate.property'
+ _description = 'Estate property'
+ _order = 'id desc'
+
+ name = fields.Char("Name", required=True)
+ description = fields.Text("Description")
+ postcode = fields.Char("Postcode")
+ date_availability = fields.Date("Available From", copy=False, default=lambda x: fields.Date.add(fields.Date.today(), months=3))
+ expected_price = fields.Float("Expected Price", required=True)
+ selling_price = fields.Float("Selling Price", readonly=True, copy=False)
+ bedrooms = fields.Integer("Bedrooms", default=2)
+ living_area = fields.Integer("Living Area (sqm)")
+ facades = fields.Integer("Facades")
+ garage = fields.Boolean("Garage")
+ garden = fields.Boolean("Garden")
+ garden_area = fields.Integer("Garden Area (sqm)")
+ garden_orientation = fields.Selection(
+ string="Type",
+ selection=[('north', "North"), ('south', "South"), ('east', "East"), ('west', "West")],
+ )
+ active = fields.Boolean("Active", default=True)
+ state = fields.Selection(
+ string="Status",
+ selection=[('new', "New"), ('offer_received', "Offer Received"), ('offer_accepted', "Offer Accepted"), ('sold', "Sold"), ('cancelled', "Cancelled")],
+ default='new',
+ required=True,
+ copy=False,
+ )
+ property_type_id = fields.Many2one('estate.property.type', string="Property Type")
+ user_id = fields.Many2one('res.users', string="Salesman", default=lambda self: self.env.user)
+ partner_id = fields.Many2one('res.partner', string="Buyer", copy=False)
+ property_tag_ids = fields.Many2many('estate.property.tag')
+ property_offer_ids = fields.One2many('estate.property.offer', 'property_id')
+ total_area = fields.Float(compute='_compute_total_area')
+ best_offer = fields.Float(compute="_compute_best_offer")
+
+ _sql_constraints = [
+ ('positive_expected_price', 'CHECK(expected_price > 0)', "A property expected price must be strictly positive"),
+ ('positive_selling_price', 'CHECK(selling_price >= 0)', "A property selling price must be positive"),
+ ]
+
+ @api.depends('living_area', 'garden_area')
+ def _compute_total_area(self):
+ for record in self:
+ record.total_area = record.living_area + record.garden_area
+
+ @api.depends('property_offer_ids.price')
+ def _compute_best_offer(self):
+ for record in self:
+ if not record.property_offer_ids:
+ record.best_offer = 0
+ else:
+ record.best_offer = max(record.property_offer_ids.mapped('price'))
+
+ @api.onchange('garden')
+ def _onchange_garden(self):
+ if self.garden:
+ self.garden_area = 10
+ self.garden_orientation = 'north'
+ else:
+ self.garden_area = 0
+ self.garden_orientation = False
+
+ def action_set_sold(self):
+ for record in self:
+ if self.state == 'cancelled':
+ raise UserError(_("Property is cancelled and cannot be sold."))
+
+ record.state = 'sold'
+
+ return True
+
+ def action_set_cancelled(self):
+ for record in self:
+ if self.state == 'sold':
+ raise UserError(_("Sold properties cannot be cancelled."))
+
+ record.state = 'cancelled'
+ record.active = False
+
+ return True
+
+ @api.constrains('selling_price')
+ def _check_selling_price(self):
+ for record in self:
+ min_selling_price = record.expected_price * 0.9
+
+ if not float_is_zero(record.selling_price, precision_digits=2) and float_compare(record.selling_price, min_selling_price, precision_digits=2) < 0:
+ raise UserError(_(
+ "The selling price must be at least 90 percent of the expected price! You must reduce the expected price if you want to accept this offer."
+ ))
diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py
new file mode 100644
index 00000000000..d9c686688f3
--- /dev/null
+++ b/estate/models/estate_property_offer.py
@@ -0,0 +1,71 @@
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+
+class PropertyOffer(models.Model):
+ _name = 'estate.property.offer'
+ _description = 'An offer made for the property'
+ _order = 'price desc'
+
+ price = fields.Float("Price")
+ status = fields.Selection(
+ string="Status",
+ selection=[('accepted', "Accepted"), ('refused', "Refused")],
+ copy=False,
+ )
+ partner_id = fields.Many2one('res.partner', required=True)
+ property_id = fields.Many2one('estate.property', required=True)
+ validity = fields.Integer("Validity (days)", default=7)
+ date_deadline = fields.Date("Deadline", compute='_compute_deadline', inverse='_inverse_deadline')
+ property_type_id = fields.Many2one(related='property_id.property_type_id')
+
+ _sql_constraints = [
+ ('positive_offer_price', 'CHECK(price > 0)', "An offer price must be strictly positive"),
+ ]
+
+ @api.depends('validity', 'create_date')
+ def _compute_deadline(self):
+ for record in self:
+ if not record.validity:
+ record.date_deadline = False
+ else:
+ record.date_deadline = fields.Date.add(fields.Date.today(), days=record.validity)
+
+ def _inverse_deadline(self):
+ for record in self:
+ if not record.date_deadline:
+ record.validity = 0
+ else:
+ record.validity = (record.date_deadline - fields.Date.today()).days
+
+ def action_confirm(self):
+ for record in self:
+ record.status = 'accepted'
+ record.property_id.selling_price = record.price
+ record.property_id.partner_id = record.partner_id
+ record.property_id.state = 'offer_accepted'
+
+ for offer in record.property_id.property_offer_ids:
+ if offer.id != record.id:
+ offer.action_refuse()
+
+ return True
+
+ def action_refuse(self):
+ for record in self:
+ record.status = 'refused'
+
+ return True
+
+ @api.model_create_multi
+ def create(self, vals):
+ for val in vals:
+ val_property_id = val.get('property_id')
+ property = self.env['estate.property'].browse(val_property_id)
+
+ if property.best_offer >= val.get('price'):
+ raise UserError(_("A new offer price needs to be greater than current best offer."))
+
+ property.state = 'offer_received'
+
+ return super().create(vals)
diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py
new file mode 100644
index 00000000000..d82061243d4
--- /dev/null
+++ b/estate/models/estate_property_tag.py
@@ -0,0 +1,14 @@
+from odoo import fields, models
+
+
+class PropertyTag(models.Model):
+ _name = 'estate.property.tag'
+ _description = 'Tag of properties'
+ _order = 'name asc'
+
+ name = fields.Char("Name", required=True)
+ color = fields.Integer("Color")
+
+ _sql_constraints = [(
+ 'unique_name', 'unique(name)', "A property tag name must be unique",
+ )]
diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py
new file mode 100644
index 00000000000..83170d16b3a
--- /dev/null
+++ b/estate/models/estate_property_type.py
@@ -0,0 +1,25 @@
+from odoo import api, fields, models
+
+
+class PropertyType(models.Model):
+ _name = 'estate.property.type'
+ _description = 'Type of property'
+ _order = 'name asc'
+
+ name = fields.Char("Name", required=True)
+ property_ids = fields.One2many('estate.property', 'property_type_id')
+ sequence = fields.Integer("Sequence")
+ offer_ids = fields.One2many('estate.property.offer', 'property_type_id')
+ offer_count = fields.Integer("Count", compute='_compute_offer_count')
+
+ _sql_constraints = [
+ ('unique_name', 'unique(name)', "A property type name must be unique"),
+ ]
+
+ @api.depends('offer_ids')
+ def _compute_offer_count(self):
+ for record in self:
+ if record.offer_ids:
+ record.offer_count = len(record.offer_ids)
+ else:
+ record.offer_count = 0
diff --git a/estate/models/res_users.py b/estate/models/res_users.py
new file mode 100644
index 00000000000..2140b38c8ec
--- /dev/null
+++ b/estate/models/res_users.py
@@ -0,0 +1,12 @@
+from odoo import fields, models
+
+
+class ResUsers(models.Model):
+ _inherit = 'res.users'
+
+ property_ids = fields.One2many(
+ 'estate.property',
+ 'user_id',
+ string="Properties",
+ domain="[('state', 'in', ['new', 'offer_received'])]",
+ )
diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv
new file mode 100644
index 00000000000..e20ec4dd90b
--- /dev/null
+++ b/estate/security/ir.model.access.csv
@@ -0,0 +1,5 @@
+id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
+access_estate_property,access.estate.property,model_estate_property,base.group_user,1,1,1,1
+access_estate_property_type,access.estate.property.type,model_estate_property_type,base.group_user,1,1,1,1
+access_estate_property_tag,access.estate.property.tag,model_estate_property_tag,base.group_user,1,1,1,1
+access_estate_property_offer,access.estate.property.offer,model_estate_property_offer,base.group_user,1,1,1,1
diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml
new file mode 100644
index 00000000000..9471f8c00c0
--- /dev/null
+++ b/estate/views/estate_menus.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml
new file mode 100644
index 00000000000..05be5c69de5
--- /dev/null
+++ b/estate/views/estate_property_offer_views.xml
@@ -0,0 +1,43 @@
+
+
+
+ estate.property.offer.list
+ estate.property.offer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.offer.form
+ estate.property.offer
+
+
+
+
+
+
+ Property Offers
+ estate.property.offer
+ list
+ [('property_type_id', '=', active_id)]
+
+
diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml
new file mode 100644
index 00000000000..0fb2d508797
--- /dev/null
+++ b/estate/views/estate_property_tag_views.xml
@@ -0,0 +1,34 @@
+
+
+
+ estate.property.tag.list
+ estate.property.tag
+
+
+
+
+
+
+
+
+ estate.property.tag.form
+ estate.property.tag
+
+
+
+
+
+
+ Property Tags
+ 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..5d94593b671
--- /dev/null
+++ b/estate/views/estate_property_type_views.xml
@@ -0,0 +1,51 @@
+
+
+
+ estate.property.type.list
+ estate.property.type
+
+
+
+
+
+
+
+
+
+ estate.property.type.form
+ estate.property.type
+
+
+
+
+
+
+ Property Types
+ estate.property.type
+ list,form
+
+
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml
new file mode 100644
index 00000000000..29fc2a4a286
--- /dev/null
+++ b/estate/views/estate_property_views.xml
@@ -0,0 +1,131 @@
+
+
+
+ estate.property.view.search
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.list
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.form
+ estate.property
+
+
+
+
+
+
+ estate.property.view.kanban
+ estate.property
+
+
+
+
+
+
+
+
+ Expected Price:
+
+
+
+ Best Offer:
+
+
+
+ Selling Price:
+
+
+
+
+
+
+
+
+
+ Properties
+ estate.property
+ list,form,kanban
+ {'search_default_available': True}
+
+
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml
new file mode 100644
index 00000000000..827687039cf
--- /dev/null
+++ b/estate/views/res_users_views.xml
@@ -0,0 +1,15 @@
+
+
+
+ res.users.view.form.inherit.estate
+ res.users
+
+
+
+
+
+
+
+
+
+
diff --git a/estate_account/__init__.py b/estate_account/__init__.py
new file mode 100644
index 00000000000..0650744f6bc
--- /dev/null
+++ b/estate_account/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py
new file mode 100644
index 00000000000..ade978fcac5
--- /dev/null
+++ b/estate_account/__manifest__.py
@@ -0,0 +1,9 @@
+{
+ 'name': "Estate Accounting",
+ 'version': '1.0',
+ 'depends': ['base', 'account', 'estate'],
+ 'data': [
+ ],
+ 'application': True,
+ 'license': 'LGPL-3',
+}
diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py
new file mode 100644
index 00000000000..5e1963c9d2f
--- /dev/null
+++ b/estate_account/models/__init__.py
@@ -0,0 +1 @@
+from . import estate_property
diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py
new file mode 100644
index 00000000000..08c40ee9b06
--- /dev/null
+++ b/estate_account/models/estate_property.py
@@ -0,0 +1,28 @@
+from odoo import Command, models
+
+
+class EstateProperty(models.Model):
+ _inherit = 'estate.property'
+
+ def action_set_sold(self):
+ result = super().action_set_sold()
+
+ for record in self:
+ self.env['account.move'].create({
+ 'partner_id': record.partner_id.id,
+ 'move_type': 'out_invoice',
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': f"Down payment for {record.name}",
+ 'quantity': 1,
+ 'price_unit': record.selling_price * 0.06,
+ }),
+ Command.create({
+ 'name': "Administrative fees",
+ 'quantity': 1,
+ 'price_unit': 100,
+ }),
+ ],
+ })
+
+ return result