diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index 31406e8addb..4dcfb445910 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -25,6 +25,9 @@ '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/cards/numbercard/numbercard.js b/awesome_dashboard/static/src/cards/numbercard/numbercard.js new file mode 100644 index 00000000000..d62c8f939b4 --- /dev/null +++ b/awesome_dashboard/static/src/cards/numbercard/numbercard.js @@ -0,0 +1,14 @@ +import { Component } from '@odoo/owl' + +export class NumberCard extends Component { + static template = "awesome_dashboard.numberCard" + static components = {} + static props = { + title: { + type: String + }, + value: { + type: Number | String + } + } +} diff --git a/awesome_dashboard/static/src/cards/numbercard/numbercard.xml b/awesome_dashboard/static/src/cards/numbercard/numbercard.xml new file mode 100644 index 00000000000..7de29aff7f9 --- /dev/null +++ b/awesome_dashboard/static/src/cards/numbercard/numbercard.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/awesome_dashboard/static/src/cards/piechartcard/piechart.js b/awesome_dashboard/static/src/cards/piechartcard/piechart.js new file mode 100644 index 00000000000..a4f87153a10 --- /dev/null +++ b/awesome_dashboard/static/src/cards/piechartcard/piechart.js @@ -0,0 +1,53 @@ +"use strict"; +import { Component, onWillStart, useRef, useEffect } from '@odoo/owl' +import { AssetsLoadingError, loadJS } from '@web/core/assets'; + +export class PieChartCard extends Component { + + static template = "awesome_dashboard.pieChartCard"; + static components = {} + static props = { + title: { + type: String + }, + value: { + type: Object + } + } + + setup() { + this.canvasRef = useRef("canvas"); + + onWillStart(async () => { + try { + await loadJS(["/web/static/lib/Chart/Chart.js"]); + } + catch (error) { + if (!(error instanceof AssetsLoadingError)) { + throw error; + } + } + }) + + useEffect(() => this.renderPieChart()) + } + + renderPieChart() { + const ctx = this.canvasRef.el.getContext("2d"); + if (this.chart) { + this.chart.destroy(); + } + this.chart = new Chart(ctx, { + type: "pie", + data: { + labels: Object.keys(this.props.value || []), //["S", "M", "L", "XL", "XXL"], + datasets: [{ + data: Object.values(this.props.value || []), // [10, 20, 15, 5, 2] + }], + }, + options: { + responsive: true, + }, + }); + } +} diff --git a/awesome_dashboard/static/src/cards/piechartcard/piechart.xml b/awesome_dashboard/static/src/cards/piechartcard/piechart.xml new file mode 100644 index 00000000000..e4d3d434cbf --- /dev/null +++ b/awesome_dashboard/static/src/cards/piechartcard/piechart.xml @@ -0,0 +1,6 @@ + + + + + + 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..cff6407fafd --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,85 @@ +/** @odoo-module **/ + +import { Component, useState } from "@odoo/owl"; +import { DashboardItem } from "../dashboarditem/dashboarditem"; +import { Layout } from '@web/search/layout' +import { PieChartCard } from "../cards/piechartcard/piechart"; +import { NumberCard } from "../cards/numbercard/numbercard"; +import { ItemConfigurationPopup } from "./item_configuration_popup/item_configuration_popup"; +import { _t } from "@web/core/l10n/translation"; +import { browser } from "@web/core/browser/browser"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +const dashboardRegistry = registry.category("awesome_dashboard.dashboard"); + +export class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { Layout, DashboardItem, PieChartCard, NumberCard, ItemConfigurationPopup } + + setup() { + this.action = useService("action"); + this.state = useState({ items: [], chart: {}, itemConfigs: {} }); + this.statisticsService = useService('statistics'); + this.statsResult = useState(this.statisticsService); + this.dialogService = useService("dialog"); + this.getBrowserLocalStorageData(); + } + + get items() { + return dashboardRegistry.get("awesome_dashboard.items"); + } + + get chart() { + return this.statsResult.orders_by_size; + } + + showDialog() { + this.updateItemConfig = this.updateItemConfig.bind(this); + this.closeWrapper = this.closeWrapper.bind(this); + this.dialogService.add(ItemConfigurationPopup, { + items: this.items, + itemConfigs: this.state.itemConfigs, + closeWrapper: this.closeWrapper, + updateItemConfigCallback: this.updateItemConfig + }); + } + + openCustomers() { + this.action.doAction("contacts.action_contacts"); + } + + openLeads() { + this.action.doAction({ + type: 'ir.actions.act_window', + name: _t('Leads'), + target: 'current', + res_model: 'crm.lead', + views: [[false, 'kanban'], [false, 'list'], [false, 'form']], // [view_id, view_type] + }); + } + + closeWrapper() { + this.getBrowserLocalStorageData(); + } + + updateItemConfig(updated_itemconfigs) { + this.state.itemConfigs = updated_itemconfigs; + } + + getBrowserLocalStorageData() { + let item_configuration_localdata = browser.localStorage.getItem("awesome_dashboard.item_configuration"); + if (item_configuration_localdata) { + this.state.itemConfigs = JSON.parse(item_configuration_localdata); + } + else { + let initialToggleState = {}; + for (const item of this.items) { + initialToggleState[item.id] = true; + } + this.state.itemConfigs = initialToggleState; + } + } +} + +registry.category("lazy_components").add("awesome_dashboard.dashboard", 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..449126d697e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.scss @@ -0,0 +1,4 @@ +.o_dashboard { + display: flex; + flex-wrap: wrap; +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..876346ab875 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,22 @@ + + + + + + Customers + Leads + + + + + + + + + + + + + + + diff --git a/awesome_dashboard/static/src/dashboard/item_configuration_popup/item_configuration_popup.js b/awesome_dashboard/static/src/dashboard/item_configuration_popup/item_configuration_popup.js new file mode 100644 index 00000000000..43bc793144a --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/item_configuration_popup/item_configuration_popup.js @@ -0,0 +1,53 @@ +import { Component, useState } from '@odoo/owl' +import { Dialog } from "@web/core/dialog/dialog"; +import { CheckBox } from "@web/core/checkbox/checkbox"; +import { browser } from "@web/core/browser/browser"; + +export class ItemConfigurationPopup extends Component { + static template = "awesome_dashboard.itemConfigurationDashboard" + static props = { + items: { + type: Object + }, + itemConfigs: { + type: Object + }, + closeWrapper: { + type: Function + }, + updateItemConfigCallback: { + type: Function + } + + } + static components = { CheckBox, Dialog } + static defaultProps = {} + + setup() { + this.state = useState({ + itemConfigs: this.props.itemConfigs + }); + } + + confirm() { + this.setBrowserLocalStorageData(); + this.closeWrapper(); + } + + toggleItemConfigCard(itemid, checked) { + this.state.itemConfigs[itemid] = checked; + this.props.updateItemConfigCallback(this.state.itemConfigs) + } + + setBrowserLocalStorageData() { + browser.localStorage.setItem( + "awesome_dashboard.item_configuration", + JSON.stringify(this.state.itemConfigs) + ); + } + + closeWrapper() { + this.props.closeWrapper(); + this.props.close(); + } +} diff --git a/awesome_dashboard/static/src/dashboard/item_configuration_popup/item_configuration_popup.xml b/awesome_dashboard/static/src/dashboard/item_configuration_popup/item_configuration_popup.xml new file mode 100644 index 00000000000..57e8e8ba135 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/item_configuration_popup/item_configuration_popup.xml @@ -0,0 +1,26 @@ + + + + + + + Dashboard Item Configuration + + + + + Which card do you wish to see? + + + + + + + + + Done + + + + + diff --git a/awesome_dashboard/static/src/dashboard_actions.js b/awesome_dashboard/static/src/dashboard_actions.js new file mode 100644 index 00000000000..19ca8e25110 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_actions.js @@ -0,0 +1,11 @@ +import { registry } from '@web/core/registry' +import { Component, xml } from "@odoo/owl"; +import { LazyComponent } from '@web/core/assets' + +class AwesomeDashboardActions extends Component { + static components = { LazyComponent } + static template = xml` + + `; +} +registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboardActions); diff --git a/awesome_dashboard/static/src/dashboarditem/dashboarditem.js b/awesome_dashboard/static/src/dashboarditem/dashboarditem.js new file mode 100644 index 00000000000..f0a246916dd --- /dev/null +++ b/awesome_dashboard/static/src/dashboarditem/dashboarditem.js @@ -0,0 +1,18 @@ +import { Component } from '@odoo/owl'; + +export class DashboardItem extends Component { + static template = "awesome_dashboard.dashboardItem" + static components = {} + static props = { + size: { + type: Number, + optional: true, + default: 2 + }, + slots: { + type: Object + } + } + + setup() {} +} diff --git a/awesome_dashboard/static/src/dashboarditem/dashboarditem.xml b/awesome_dashboard/static/src/dashboarditem/dashboarditem.xml new file mode 100644 index 00000000000..dcecfbe29fc --- /dev/null +++ b/awesome_dashboard/static/src/dashboarditem/dashboarditem.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/awesome_dashboard/static/src/data/items.js b/awesome_dashboard/static/src/data/items.js new file mode 100644 index 00000000000..ef04b1c4bf3 --- /dev/null +++ b/awesome_dashboard/static/src/data/items.js @@ -0,0 +1,67 @@ +import { NumberCard } from "../cards/numbercard/numbercard"; +import { PieChartCard } from "../cards/piechartcard/piechart"; +import { registry } from "@web/core/registry" + +const ITEMS = [ + { + id: "average_quantity", + description: "Average amount of t-shirt by order this month", + Component: NumberCard, + size: 3, + props: (data) => ({ + title: 'Average amount of t-shirt by order this month', + value: data.average_quantity, + }) + }, + { + id: "average_time", + description: "Average time for an order to go from ‘new’ to ‘sent’ or ‘cancelled`", + Component: NumberCard, + size: 3, + props: (data) => ({ + title: 'Average time for an order to go from ‘new’ to ‘sent’ or ‘cancelled`', + value: data.average_time, + }) + }, + { + id: "nb_cancelled_orders", + description: "Number of cancelled orders this month", + Component: NumberCard, + size: 3, + props: (data) => ({ + title: 'Number of cancelled orders this month', + value: data.nb_cancelled_orders, + }) + }, + { + id: "nb_new_orders", + description: "Number of new orders this month", + Component: NumberCard, + size: 3, + props: (data) => ({ + title: 'Number of new orders this month', + value: data.nb_new_orders, + }) + }, + { + id: "total_amount", + description: "Total amount of new orders this month", + Component: NumberCard, + size: 3, + props: (data) => ({ + title: 'Total amount of new orders this month', + value: data.total_amount, + }) + }, + { + id: "orders_by_size", + description: "Graph Representation", + Component: PieChartCard, + props: (data) => ({ + title: 'Graph Representation', + value: data.orders_by_size, + }) + } +] + +registry.category("awesome_dashboard.dashboard").add("awesome_dashboard.items", ITEMS); diff --git a/awesome_dashboard/static/src/services/statistics_service.js b/awesome_dashboard/static/src/services/statistics_service.js new file mode 100644 index 00000000000..6f43104a771 --- /dev/null +++ b/awesome_dashboard/static/src/services/statistics_service.js @@ -0,0 +1,32 @@ +import { reactive } from '@odoo/owl' +import { registry } from '@web/core/registry'; +import { rpc } from "@web/core/network/rpc"; + +let stats = reactive({ + average_quantity: 0, + nb_new_orders: 0, + nb_cancelled_orders: 0, + average_time: 0, + orders_by_size: {}, + total_amount: 0 + +}, () => {}); + +const loadStatistics = async () => { + let result = await rpc("/awesome_dashboard/statistics"); + stats.nb_new_orders = stats.nb_new_orders + 1 + Object.assign(stats, result); +}; + +export const statisticsService = { + dependencies: [], + start() { + loadStatistics(); + // setInterval(() => { + // loadStatistics(); + // }, 1000); + return stats; + }, +}; + +registry.category("services").add("statistics", statisticsService); diff --git a/awesome_owl/__manifest__.py b/awesome_owl/__manifest__.py index 77abad510ef..b0e71046138 100644 --- a/awesome_owl/__manifest__.py +++ b/awesome_owl/__manifest__.py @@ -36,6 +36,7 @@ ('include', 'web._assets_core'), 'web/static/src/libs/fontawesome/css/font-awesome.css', 'awesome_owl/static/src/**/*', + 'awesome_owl/static/css/**' ], }, 'license': 'AGPL-3' diff --git a/awesome_owl/static/css/style.css b/awesome_owl/static/css/style.css new file mode 100644 index 00000000000..2adccea50cc --- /dev/null +++ b/awesome_owl/static/css/style.css @@ -0,0 +1,10 @@ +.fade-in { + opacity: 0; + transform: translateY(-10px); + transition: opacity 0.5s ease, transform 0.5s ease; +} + +.fade-in.show { + opacity: 1; + transform: translateY(0); +} diff --git a/awesome_owl/static/src/components/card/card.js b/awesome_owl/static/src/components/card/card.js new file mode 100644 index 00000000000..29507c92711 --- /dev/null +++ b/awesome_owl/static/src/components/card/card.js @@ -0,0 +1,21 @@ +import { Component, markup, useState } from '@odoo/owl' + +export class Card extends Component { + static template = "awesome_owl.Card" + static components = {} + static props = { + title: { type: String, default: "Card Title", required: true }, + slots: { type: Object }, + visit: { type: String, default: "Visit us", optional: true } + } + + setup() { + super.setup(); + this.state = useState({ isOpen: true }) + this.markup_visit = markup(this.props.visit) + } + + toggleCard() { + this.state.isOpen = !this.state.isOpen; + } +} diff --git a/awesome_owl/static/src/components/card/card.xml b/awesome_owl/static/src/components/card/card.xml new file mode 100644 index 00000000000..7155aa8af76 --- /dev/null +++ b/awesome_owl/static/src/components/card/card.xml @@ -0,0 +1,28 @@ + + + + + + + + + + Close + + + Open + + + + + + + + + + + + + + + diff --git a/awesome_owl/static/src/components/counter/counter.js b/awesome_owl/static/src/components/counter/counter.js new file mode 100644 index 00000000000..881415604fd --- /dev/null +++ b/awesome_owl/static/src/components/counter/counter.js @@ -0,0 +1,23 @@ +import { Component, useState } from "@odoo/owl"; + +export class Counter extends Component { + + static template = "awesome_owl.Counter"; + static components = {} + static props = { + onChange: { + type: Function, + optional: true + } + } + + setup() { + super.setup(); + this.state = useState({ value: 1 }) + } + + increment() { + this.state.value++; + this.props.onChange?.(this.state.value); + } +} diff --git a/awesome_owl/static/src/components/counter/counter.xml b/awesome_owl/static/src/components/counter/counter.xml new file mode 100644 index 00000000000..115b935fc2c --- /dev/null +++ b/awesome_owl/static/src/components/counter/counter.xml @@ -0,0 +1,14 @@ + + + + + + Counter + + Increment + Value: + + + + + diff --git a/awesome_owl/static/src/components/playground/playground.js b/awesome_owl/static/src/components/playground/playground.js new file mode 100644 index 00000000000..a6d4d28b228 --- /dev/null +++ b/awesome_owl/static/src/components/playground/playground.js @@ -0,0 +1,58 @@ +/** @odoo-module **/ + +import { Component, useEffect, useState } from "@odoo/owl"; +import { Counter } from "../counter/counter"; +import { Card } from "../card/card"; +import { TodoList } from "../todo/todolist/todolist"; + +export class Playground extends Component { + static template = "awesome_owl.Playground"; + static components = { Counter, Card, TodoList } + static props = {} + + setup() { + super.setup(); + this.state = useState({ counters: 2, sum: 2 }); + + useEffect(() => { + this.state.sum += this.state.counters; + },()=>[this.state.counters]); + + this.cards = [ + { + title: "Discover the Stars", + body: "Explore the mysteries of the universe and learn about constellations and galaxies.", + visit: "https://nasa.gov" + }, + { + title: "Healthy Living Tips", + body: "Simple and effective ways to improve your health and boost your energy every day.", + visit: "https://www.healthline.com" + }, + { + title: "Travel on a Budget", + body: "Find out how to see the world without breaking the bank — travel smart and save money.", + visit: "https://www.nomadicmatt.com" + }, + { + title: "Mastering JavaScript", + body: "Step-by-step guide to becoming proficient in JavaScript and building dynamic web apps.", + visit: "https://javascript.info" + }, + { + title: "Cooking Made Easy", + body: "Delicious and quick recipes for busy people who love home-cooked meals.", + visit: "https://www.budgetbytes.com" + }, + { + title: "Mindfulness & Meditation", + body: "Learn techniques to reduce stress and enhance your mental wellbeing through mindfulness.", + visit: "https://www.headspace.com" + } + ]; + } + + incrementSum() { + this.state.sum++; + } +} diff --git a/awesome_owl/static/src/components/playground/playground.xml b/awesome_owl/static/src/components/playground/playground.xml new file mode 100644 index 00000000000..31689964e48 --- /dev/null +++ b/awesome_owl/static/src/components/playground/playground.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + Total: + + + + + + Card Components + + + + + + + + + + + + + + + + + + + diff --git a/awesome_owl/static/src/components/todo/todoitem/todoitem.js b/awesome_owl/static/src/components/todo/todoitem/todoitem.js new file mode 100644 index 00000000000..eded08f5728 --- /dev/null +++ b/awesome_owl/static/src/components/todo/todoitem/todoitem.js @@ -0,0 +1,36 @@ +import { Component, onMounted, useRef } from "@odoo/owl" +import { Card } from "../../card/card" + +export class TodoItem extends Component { + static template = "awesome_owl.TodoItem" + static components = { Card } + static props = { + id: { type: Number }, + description: { type: String }, + isCompleted: { type: Boolean, default: false }, + markTodocallback: { type: Function }, + deleteTodocallback: { type: Function } + } + + setup() { + super.setup(); + this.todo_ref = useRef("todo_ref"); + this.todo_togglestatus = useRef("todo_togglestatus"); + + onMounted(() => { + requestAnimationFrame(() => { + this.todo_ref.el.classList.add("show"); + }); + + this.todo_togglestatus.el.addEventListener("change", () => { + this.props.markTodocallback(this.props.id); + + }) + }); + + } + + deleteTodo() { + this.props.deleteTodocallback(this.props.id) + } +} diff --git a/awesome_owl/static/src/components/todo/todoitem/todoitem.xml b/awesome_owl/static/src/components/todo/todoitem/todoitem.xml new file mode 100644 index 00000000000..2ca63c4ce59 --- /dev/null +++ b/awesome_owl/static/src/components/todo/todoitem/todoitem.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/awesome_owl/static/src/components/todo/todolist/todolist.js b/awesome_owl/static/src/components/todo/todolist/todolist.js new file mode 100644 index 00000000000..1031fcc8e48 --- /dev/null +++ b/awesome_owl/static/src/components/todo/todolist/todolist.js @@ -0,0 +1,45 @@ +import { Component, useState, useRef } from "@odoo/owl" +import { TodoItem } from "../todoitem/todoitem"; +import { useAutofocus } from '../../../utils.js'; + + +export class TodoList extends Component { + static template = "awesome_owl.TodoList" + static components = { TodoItem } + static props = {} + + setup() { + super.setup(); + this.todos = useState([]); + this.todoInputRef = useRef("todoInputRef"); + this.markTodo = this.markTodo.bind(this); + + useAutofocus("todoInputRef"); + + } + + addTodo(ev) { + if(ev.keyCode == 13) { + let max_id = Math.max(...this.todos.map(todo => todo.id), 0); + let new_description = this.todoInputRef.el.value; + this.todos.push({ id: max_id + 1, description: new_description, isCompleted: false}); + this.todoInputRef.el.value = ""; + } + } + + markTodo(todo_id) { + this.todos.forEach((todo, index) => { + if (todo.id == todo_id) { + todo.isCompleted = !todo.isCompleted; + } + }); + } + + deleteTodo(todo_id) { + this.todos.forEach((todo, index) => { + if (todo.id == todo_id) { + this.todos.splice(index,1) + } + }); + } +} diff --git a/awesome_owl/static/src/components/todo/todolist/todolist.xml b/awesome_owl/static/src/components/todo/todolist/todolist.xml new file mode 100644 index 00000000000..23d8007c6d3 --- /dev/null +++ b/awesome_owl/static/src/components/todo/todolist/todolist.xml @@ -0,0 +1,28 @@ + + + + + Todo List + + + + + + + + + + + + diff --git a/awesome_owl/static/src/main.js b/awesome_owl/static/src/main.js index 1af6c827e0b..558f77f8ea5 100644 --- a/awesome_owl/static/src/main.js +++ b/awesome_owl/static/src/main.js @@ -1,6 +1,6 @@ import { whenReady } from "@odoo/owl"; import { mountComponent } from "@web/env"; -import { Playground } from "./playground"; +import { Playground } from "./components/playground/playground"; const config = { dev: true, diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js deleted file mode 100644 index 657fb8b07bb..00000000000 --- a/awesome_owl/static/src/playground.js +++ /dev/null @@ -1,7 +0,0 @@ -/** @odoo-module **/ - -import { Component } from "@odoo/owl"; - -export class Playground extends Component { - static template = "awesome_owl.playground"; -} diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml deleted file mode 100644 index 4fb905d59f9..00000000000 --- a/awesome_owl/static/src/playground.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - hello world - - - - diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..3c5908f4fdb --- /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..f6905b8bc48 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,31 @@ +{ + 'name': 'Real Estate', + 'version': '1.0', + 'depends': ['base'], + 'author': 'Jay Chauhan', + 'category': 'Category', + 'description': """ + Real Estate Management Module + + This module allows managing real estate properties with detailed information including: + - Property title, description, and postcode + - Availability date with default scheduling + - Pricing details (expected and selling price) + - Property features like bedrooms, living area, facades, garage, and garden + - Garden specifics including area and orientation + - Status tracking through different stages: new, offer received, offer accepted, sold, cancelled + - Active flag to easily archive or activate properties + - User-friendly views and search with filters and group-by options for efficient property management + """, + 'data': [ + 'views/estate_property_offer_view.xml', + 'views/estate_property_type_view.xml', + 'views/estate_property_tag_view.xml', + 'views/estate_property_view.xml', + 'views/estate_menu.xml', + 'security/ir.model.access.csv', + ], + 'installable': True, + 'application': True, + 'license': 'LGPL-3' +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..1b7404e81a1 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property, estate_property_type, estate_property_tag, estate_property_offer, inherited_user diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..a5ad9988cd6 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,160 @@ +# Python Imports +from datetime import date +from dateutil.relativedelta import relativedelta + +# Odoo Imports +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools.float_utils import float_compare, float_is_zero + + +class EstateProperty(models.Model): + _name = 'estate.property' + _description = 'Estate Property' + _order = 'id desc' + + # ----------------------------- + # SQL Constraints + # ----------------------------- + _sql_constraints = [ + ('check_expected_price', 'CHECK(expected_price > 0)', 'Expected price cannot be negative.') + ] + + # ----------------------------- + # Field Declarations + # ----------------------------- + name = fields.Char(string='Title', required=True, help='Title or name of the property.') + description = fields.Text(string='Description', help='Detailed description of the property.') + postcode = fields.Char(string='Postcode', help='Postal code of the property location.') + date_availability = fields.Date( + string='Availability From', + copy=False, + default=(date.today() + relativedelta(months=3)), + help='Date from which the property will be available.' + ) + expected_price = fields.Float(string='Expected Price', required=True, help='Price expected by the seller for this property.') + selling_price = fields.Float(string='Selling Price', readonly=True, copy=False, help='Final selling price once the property is sold.') + bedrooms = fields.Integer(string='Bedrooms', default=2, help='Number of bedrooms in the property.') + living_area = fields.Integer(string='Living Area (sqm)', help='Living area size in square meters.') + facades = fields.Integer(string='Facades', help='Number of facades of the property.') + garage = fields.Integer(string='Garage', help='Number of garage spaces.') + garden = fields.Boolean(string='Garden', help='Whether the property has a garden.') + garden_area = fields.Integer(string='Garden Area (sqm)', help='Size of the garden area in square meters.') + garden_orientation = fields.Selection( + string='Garden Orientation', + selection=[ + ('north', 'North'), + ('south', 'South'), + ('east', 'East'), + ('west', 'West'), + ], + default='north', help='Direction the garden faces.' + ) + state = fields.Selection( + string='Status', + selection=[ + ('new', 'New'), + ('offer_received', 'Offer Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('cancelled', 'Cancelled'), + ], + required=True, copy=False, default='new', help='Current status of the property.' + ) + active = fields.Boolean(string='Active', default=True, help='Whether the property is active and visible.') + property_type_id = fields.Many2one('estate.property.type', string='Property Type', help='Type or category of the property.') + buyer_id = fields.Many2one('res.partner', string='Buyer', copy=False, help='Partner who bought the property.') + sales_id = fields.Many2one('res.users', string='Salesman', default=lambda self: self.env.user, help='Salesperson responsible for the property.') + tag_ids = fields.Many2many('estate.property.tag', string='Tags', help='Tags to classify the property.') + offer_ids = fields.One2many('estate.property.offer', 'property_id', string='Offers', help='Offers made on this property.') + + # ----------------------------- + # Computed Fields + # ----------------------------- + total = fields.Float( + string='Total (sqm)', + compute='_compute_total_area', + help='Total area of the property including living and garden areas.' + ) + best_price = fields.Float( + string='Best Offer', + compute='_compute_best_price', + help='Highest offer price received for the property.' + ) + + @api.depends('living_area', 'garden_area') + def _compute_total_area(self): + """Compute total area as sum of living area and garden area.""" + for record in self: + record.total = (record.living_area or 0) + (record.garden_area or 0) + + @api.depends('offer_ids.price') + def _compute_best_price(self): + """Compute highest offer price or 0 if no offers.""" + for record in self: + offer_prices = record.offer_ids.mapped('price') + record.best_price = max(offer_prices) if offer_prices else 0.0 + + # ----------------------------- + # Action Methods + # ----------------------------- + def action_sold(self): + """Set property state to 'sold', with validation against invalid states.""" + if not self.offer_ids: + raise UserError('No offer available for this property.') + return + + for record in self: + if record.state == 'cancelled': + raise UserError('A cancelled property cannot be set as sold.') + elif record.state == 'sold': + raise UserError('Property is already sold.') + else: + record.state = 'sold' + + def action_cancel(self): + """Set property state to 'cancelled', with validation against invalid states.""" + if not self.offer_ids: + raise UserError('No offer available for this property.') + return + + for record in self: + if record.state == 'sold': + raise UserError('A sold property cannot be cancelled.') + elif record.state == 'cancelled': + raise UserError('Property is already cancelled.') + else: + record.state = 'cancelled' + + # ----------------------------- + # Constraints + # ----------------------------- + @api.constrains('selling_price', 'expected_price') + def _check_selling_price_above_90_percent(self): + """ + Validate selling price with float precision. + Ignores zero selling price, otherwise enforces minimum 90% threshold. + """ + for record in self: + if float_is_zero(record.selling_price, precision_digits=2): + continue + min_acceptable_price = 0.9 * record.expected_price + if float_compare(record.selling_price, min_acceptable_price, precision_digits=2) < 0: + raise ValidationError(_( + "The selling price must be at least 90%% of the expected price.\n" + "Expected Price: %(expected_price).2f\nSelling Price: %(selling_price).2f", + { + 'expected_price': record.expected_price, + 'selling_price': record.selling_price + } + )) + + @api.ondelete(at_uninstall=False) + def _check_can_be_deleted(self): + """ + Restrict deletion to properties in 'new' or 'cancelled' state. + Raises UserError otherwise. + """ + for record in self: + if record.state not in ['new', 'cancelled']: + raise UserError('You can only delete properties that are New or Cancelled.') diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..2dce296e33c --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,127 @@ +# Python Imports +from datetime import timedelta + +# Odoo Imports +from odoo import _, api, fields, models +from odoo.tools import float_compare +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = 'estate.property.offer' + _description = 'Estate Property Offers' + _order = 'price desc' + + # ----------------------------- + # SQL Constraints + # ----------------------------- + _sql_constraints = [ + ('check_offer_price', 'CHECK(price > 0)', 'Offer price cannot be negative or zero.'), + ] + + # ----------------------------- + # Field Declarations + # ----------------------------- + price = fields.Float(string='Price', required=True, help='The offer price proposed by the partner.') + status = fields.Selection( + string='Status', + selection=[ + ('accepted', 'Accepted'), + ('refused', 'Refused'), + ], + copy=False, help='Current status of the offer: Accepted or Refused.' + ) + validity = fields.Integer(string='Validity (days)', default=7, help='Number of days this offer remains valid from the creation date.') + date_deadline = fields.Date( + string='Deadline', compute='_compute_date_deadline', + inverse='_inverse_date_deadline', + help='Deadline date until which the offer is valid.' + ) + partner_id = fields.Many2one('res.partner', string='Partner', required=True, help='The partner who made this offer.') + property_id = fields.Many2one('estate.property', string='Property', required=True, help='The property this offer is related to.') + property_type_id = fields.Many2one(related='property_id.property_type_id', store=True, help='Type of the related property.') + + # ----------------------------- + # Compute / Inverse Methods + # ----------------------------- + @api.depends('create_date', 'validity') + def _compute_date_deadline(self): + """ + Compute the deadline by adding the validity period (in days) to the creation date. + Uses today's date if creation date is not available. + Sets deadline to False if validity is not set. + """ + for record in self: + create_date = record.create_date.date() if record.create_date else fields.Date.context_today(record) + record.date_deadline = create_date + timedelta(days=record.validity) if record.validity else False + + def _inverse_date_deadline(self): + """ + Recalculate the validity period based on the difference between the deadline + and the creation date (or today's date if creation date is missing). + Validity is set to zero if no deadline is specified. + """ + for record in self: + create_date = record.create_date.date() if record.create_date else fields.Date.context_today(record) + if record.date_deadline: + delta = record.date_deadline - create_date + record.validity = max(delta.days, 0) + else: + record.validity = 0 + + # ----------------------------- + # CRUD Methods + # ----------------------------- + @api.model_create_multi + def create(self, vals_list): + """ + Override create to validate offer before creation: + - Ensure property and price are provided. + - Prevent creating offers lower than existing offers. + - Update property state if it's 'new'. + """ + for vals in vals_list: + property_id = vals.get('property_id') + offer_price = vals.get('price', 0.0) + if not property_id or not offer_price: + raise UserError(_('Both property and price must be provided.')) + + Property = self.env['estate.property'].browse(property_id) + + for offer in Property.offer_ids: + if float_compare(offer_price, offer.price, precision_rounding=0.01) < 0: + raise UserError(_('Cannot create an offer lower than an existing offer.')) + + if Property.state == 'new': + Property.state = 'offer_received' + + # Pass all valid vals to super + return super().create(vals_list) + + # ----------------------------- + # Action Methods + # ----------------------------- + def action_confirm(self): + """ + Confirm the offer: + - Set offer status to 'accepted'. + - Update related property status and selling details. + """ + self.ensure_one() + for record in self: + record.status = 'accepted' + record.property_id.write({ + 'state': 'offer_accepted', + 'selling_price': record.price, + 'buyer_id': record.partner_id + }) + + (self.property_id.offer_ids - record).write({'status': 'refused'}) + + def action_refuse(self): + """ + Refuse the offer by setting its status to 'refused'. + """ + self.ensure_one() + for record in self: + record.status = 'refused' diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..ee1e123f771 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,21 @@ +# Odoo Imports +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = 'estate.property.tag' + _description = 'Estate Property Tags' + _order = 'name' + + # ----------------------------- + # Fields + # ----------------------------- + name = fields.Char(string='Property Tag', required=True, help='Name of the tag used to categorize or label properties.') + color = fields.Integer(string='Color', help='Color code used to visually distinguish this tag.') + + # ----------------------------- + # SQL Constraints + # ----------------------------- + _sql_constraints = [ + ('uniq_tag_name', 'unique(name)', '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..dbe384ce16e --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,36 @@ +# Odoo Imports +from odoo import api, fields, models + + +class EstatePropertyType(models.Model): + _name = 'estate.property.type' + _description = 'Estate Property Type' + _order = 'sequence, name' + + # ----------------------------- + # Fields + # ----------------------------- + name = fields.Char(string='Property Type', required=True, help='Name of the property type (e.g., Apartment, House).') + property_ids = fields.One2many('estate.property', 'property_type_id', string='Properties', help='Properties categorized under this type.') + offer_ids = fields.One2many('estate.property.offer', 'property_type_id', string='Offers', help='Offers associated with properties of this type.') + sequence = fields.Integer(string='Sequence', default=10, help='Used to order property types in lists and views.') + offer_count = fields.Integer(string='Number of Offers', compute='_compute_offer_count', help='Total number of offers made on properties of this type.') + + # ----------------------------- + # SQL Constraints + # ----------------------------- + _sql_constraints = [ + ('uniq_property_type_name', 'UNIQUE(name)', 'Property type name must be unique.'), + ] + + # ----------------------------- + # Compute Function + # ----------------------------- + @api.depends('offer_ids') + def _compute_offer_count(self): + """ + Compute the total number of offers associated with this property type. + """ + for record in self: + record.offer_count = len(record.offer_ids) if hasattr( + record, 'offer_ids') else 0 diff --git a/estate/models/inherited_user.py b/estate/models/inherited_user.py new file mode 100644 index 00000000000..15d5322f6f2 --- /dev/null +++ b/estate/models/inherited_user.py @@ -0,0 +1,17 @@ +# Odoo Imports +from odoo import fields, models + + +class InheritedUser(models.Model): + _inherit = 'res.users' + + # ----------------------------- + # Field Declarations + # ----------------------------- + property_ids = fields.One2many( + 'estate.property', + 'sales_id', + string='Properties', + domain=[('state', 'in', ['new', 'offer_received'])], + help='Properties assigned to this salesman with status New or Offer Received.' + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..c4965173159 --- /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/static/description/building_icon.png b/estate/static/description/building_icon.png new file mode 100644 index 00000000000..de29c6e1132 Binary files /dev/null and b/estate/static/description/building_icon.png differ diff --git a/estate/views/estate_menu.xml b/estate/views/estate_menu.xml new file mode 100644 index 00000000000..8c11ae6d91d --- /dev/null +++ b/estate/views/estate_menu.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_view.xml b/estate/views/estate_property_offer_view.xml new file mode 100644 index 00000000000..d831a12faa6 --- /dev/null +++ b/estate/views/estate_property_offer_view.xml @@ -0,0 +1,52 @@ + + + + + estate.property.Offer.view.list + estate.property.offer + + + + + + + + + + + + + + + estate.property.offer.view.form + estate.property.offer + + + + + + + + + + + + + + + + + + + + + Property Offers + estate.property.offer + list,form + + + diff --git a/estate/views/estate_property_tag_view.xml b/estate/views/estate_property_tag_view.xml new file mode 100644 index 00000000000..28ad8aed3ea --- /dev/null +++ b/estate/views/estate_property_tag_view.xml @@ -0,0 +1,48 @@ + + + + + estate.property.tag.view.list + estate.property.tag + + + + + + + + + + + estate.property.tag.view.form + estate.property.tag + + + + + + + + + + + + + + estate.property.tag.view.search + estate.property.tag + + + + + + + + + + Property Tags + estate.property.tag + list,form + + + diff --git a/estate/views/estate_property_type_view.xml b/estate/views/estate_property_type_view.xml new file mode 100644 index 00000000000..b867f7d2651 --- /dev/null +++ b/estate/views/estate_property_type_view.xml @@ -0,0 +1,74 @@ + + + + + estate.property.type.view.list + estate.property.type + + + + + + + + + + + estate.property.type.view.form + estate.property.type + + + + + + + + + + + + + + Offers + + + + + + + + + + + + + + + + + + + + + + + + estate.property.type.view.search + estate.property.type + + + + + + + + + + Properties Types + estate.property.type + list,form + + + diff --git a/estate/views/estate_property_view.xml b/estate/views/estate_property_view.xml new file mode 100644 index 00000000000..c393e842ae8 --- /dev/null +++ b/estate/views/estate_property_view.xml @@ -0,0 +1,195 @@ + + + + + estate.property.view.kanban + estate.property + + + + + + + + + + + Expected Price: + + + Best Price: + + + Selling Price: + + + + + + + + + + + + + + estate.property.view.list + estate.property + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.view.search + estate.property + + + + + + + + + + + + + + + + + + + + + estate.property.view.form + estate.property + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + res.users.form.inherit.estate + res.users + + + + + + + + + + + + + + + + + + + + Properties + estate.property + kanban,list,form + {'search_default_available_properties': 1} + + + 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..1b65756e62a --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,14 @@ +# estate_account/__manifest__.py +{ + 'name': 'Estate Account', + 'version': '1.0', + 'depends': ['estate', 'account'], + 'author': 'Your Name', + 'category': 'Real Estate', + 'summary': 'Integration between Estate and Accounting', + 'description': 'Link module to integrate estate property with accounting features.', + 'installable': True, + 'application': True, + 'auto_install': 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..de0db59f039 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,47 @@ +# Odoo Imports +from odoo import _, Command, models +from odoo.exceptions import UserError + + +class EstateProperty(models.Model): + _inherit = 'estate.property' + + # ----------------------------- + # Inherited Action Methods + # ----------------------------- + def action_sold(self): + """ + Extends the base `action_sold` method to generate a customer invoice + upon property sale. Includes commission and administrative fees. + """ + res = super().action_sold() + + for property in self: + if not property.buyer_id: + raise UserError(_("Please set a buyer before generating an invoice.")) + + invoice_vals = { + 'partner_id': property.buyer_id.id, + 'move_type': 'out_invoice', + 'ref': f"Property Sale: {property.name}", + 'invoice_line_ids': [ + Command.create({ + 'name': property.name, + 'quantity': 1, + 'price_unit': property.selling_price, + }), + Command.create({ + 'name': _('6%% Commission'), + 'quantity': 1, + 'price_unit': 0.06 * property.selling_price, + }), + Command.create({ + 'name': _('Administrative Fees'), + 'quantity': 1, + 'price_unit': 100.0, + }), + ] + } + + self.env['account.move'].create(invoice_vals) + return res
+ +
Value: