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.scss b/awesome_dashboard/static/src/dashboard.scss new file mode 100644 index 00000000000..9d2b00304fc --- /dev/null +++ b/awesome_dashboard/static/src/dashboard.scss @@ -0,0 +1,15 @@ +.dashboard-card-wrapper { + transition: transform 0.3s ease, box-shadow 0.3s ease; + } + + .dashboard-card { + border-radius: 1rem; + transition: all 0.3s ease-in-out; + cursor: pointer; + } + + .dashboard-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.12); + } + \ No newline at end of file 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/components/dashboard_item/dashboard.scss b/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard.scss new file mode 100644 index 00000000000..eb88c8dbb56 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard.scss @@ -0,0 +1,29 @@ +// .o_dashboard_cards { +// display: flex; +// flex-wrap: wrap; +// gap: 1rem; +// } + +.dashboard-item { + background: white; + padding: 1.5rem; + border-radius: 0.75rem; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + min-height: 150px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + h5 { + font-size: 1rem; + margin-bottom: 0.5rem; + text-align: center; + } + + p { + font-size: 1.5rem; + font-weight: bold; + color: green; + } +} diff --git a/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.js b/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.js new file mode 100644 index 00000000000..d09af3eae16 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.js @@ -0,0 +1,6 @@ +import { Component } from "@odoo/owl"; + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem"; + static props = { size: { type: Number, optional: true, default: 1 } }; +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.xml b/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.xml new file mode 100644 index 00000000000..b5cf5ea9f37 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.xml @@ -0,0 +1,10 @@ + + +
+
+ +
+
+
+
+ \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/number_card/number_card.js b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.js new file mode 100644 index 00000000000..7183ecf0966 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.js @@ -0,0 +1,9 @@ +import { Component } from "@odoo/owl"; + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + static props = { + label: String, + value: [Number, String], + }; +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/number_card/number_card.xml b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.xml new file mode 100644 index 00000000000..ad4d2a9dea5 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.xml @@ -0,0 +1,10 @@ + + +

+ +

+

+ +

+
+
\ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.js b/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.js new file mode 100644 index 00000000000..83458025f30 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.js @@ -0,0 +1,62 @@ +/** @odoo-module **/ + +import { Component, useRef, onWillStart, onMounted, onWillUpdateProps } from '@odoo/owl'; +import { loadJS } from "@web/core/assets"; + +export class PieChart extends Component { + static template = 'awesome_dashboard.PieChart'; + + static props = { + data: Object, + }; + + setup() { + this.canvasRef = useRef('chartCanvas'); + this.chart = null; + + onWillStart(async () => { + await loadJS("/web/static/lib/Chart/Chart.js"); + }); + + onMounted(() => { + this.renderChart(); + }); + + onWillUpdateProps((nextProps) => { + if (this.chart) { + // Update dataset data with new props + this.chart.data.labels = Object.keys(nextProps.data); + this.chart.data.datasets[0].data = Object.values(nextProps.data); + this.chart.update(); // ✅ Trigger re-render + } + }); + } + + renderChart() { + const ctx = this.canvasRef.el.getContext('2d'); + this.chart = new Chart(ctx, { + type: "pie", + data: { + labels: Object.keys(this.props.data), + datasets: [ + { + label: "Orders by T-shirt Size", + data: Object.values(this.props.data), + backgroundColor: [ + "#3498db", "#2ecc71", "#f1c40f", "#e67e22", "#9b59b6" + ], + borderWidth: 1 + } + ] + }, + options: { + responsive: true, + plugins: { + legend: { + position: "right" + } + } + } + }); + } +} diff --git a/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.xml b/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.xml new file mode 100644 index 00000000000..38d6e3cc98c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.xml @@ -0,0 +1,5 @@ + +
+ +
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..d3aed40682c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,55 @@ +import { Component, useState, onWillStart } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { registry } from "@web/core/registry"; +import { Layout } from "@web/search/layout"; +import { DashboardItem } from "./components/dashboard_item/dashboard_item"; +import { NumberCard } from "./components/number_card/number_card"; +import { PieChart } from "./components/pie_chart/pie_chart"; +import { SettingDialog } from "./setting/setting_dialog"; + +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { Layout, DashboardItem, NumberCard, PieChart }; + + setup() { + this.dialogService = useService("dialog"); + this.action = useService("action"); + this.statisticsService = useService("awesome_dashboard.statistics"); + this.statistics = useState(this.statisticsService.data); + this.state = useState({ hiddenItems: JSON.parse(localStorage.getItem("awesome_dashboard_hidden_items")) || [] }); + } + + openCustomers = () => { + this.action.doAction("base.action_partner_customer_form", { + views: [[false, "kanban"]], + }); + } + + openLeads = () => { + this.action.doAction({ + type: "ir.actions.act_window", + name: "Leads", + res_model: "crm.lead", + views: [ + [false, "list"], + [false, "form"] + ] + }); + } + + getFilteredItems = () => { + const hiddenSet = new Set(this.state.hiddenItems); + return registry.category("awesome_dashboard.items").getAll().filter(item => !hiddenSet.has(item.id)); + } + + openSettings = () => { + this.dialogService.add(SettingDialog, { + onApply: (hiddenItems) => { + localStorage.setItem("awesome_dashboard_hidden_items", JSON.stringify(hiddenItems)); + this.state.hiddenItems = hiddenItems; + } + }); + } +} + +registry.category("lazy_components").add("awesome_dashboard.dashboard", AwesomeDashboard); \ 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..cb6a7b7a0c2 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,26 @@ + + + +
+ + + + + + + + +
+ + + + + + +
+
+
+
+
\ No newline at end of file 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..6bba5560dfa --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,73 @@ +import { registry } from "@web/core/registry" +import { NumberCard } from "./components/number_card/number_card"; +import { PieChart } from "./components/pie_chart/pie_chart"; +import { _t } from "@web/core/l10n/translation"; + +const dashboardRegistry = registry.category("awesome_dashboard.items"); + + +dashboardRegistry.add("average_quantity", { + id: "average_quantity", + description: _t("Average amount of t-shirt"), + component: NumberCard, + size: 2, + props: (data) => ({ + label: _t("Average Quantity"), + value: data.average_quantity, + }), +}); + +dashboardRegistry.add("average_time", { + id: "average_time", + description: _t("Average order processing time"), + component: NumberCard, + size: 2, + props: (data) => ({ + label: _t("Average time"), + value: data.average_time, + }), +}); + +dashboardRegistry.add("nb_new_orders", { + id: "nb_new_orders", + description: _t("New orders this month"), + component: NumberCard, + size: 1, + props: (data) => ({ + label: _t("New orders this month"), + value: data.nb_new_orders, + }), +}); + +dashboardRegistry.add("nb_cancelled_orders", { + id: "nb_cancelled_orders", + description: _t("Cancelled orders this month"), + component: NumberCard, + size: 1, + props: (data) => ({ + label: _t("Number of Cancelled Orders this month"), + value: data.nb_cancelled_orders, + }), +}); + +dashboardRegistry.add("total_amount", { + id: "total_amount", + description: _t("Total sales amount"), + component: NumberCard, + size: 1, + props: (data) => ({ + label: _t("Total amount of new Orders This Month"), + value: data.total_amount, + }), +}); + +dashboardRegistry.add("orders_by_size", { + id: "orders_by_size", + description: _t("Shirt orders by size"), + component: PieChart, + size: 2, + props: (data) => ({ + label: _t("Shirt orders by size"), + data: data.orders_by_size, + }), +}); \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/services/statistics.js b/awesome_dashboard/static/src/dashboard/services/statistics.js new file mode 100644 index 00000000000..51a91bc5939 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/services/statistics.js @@ -0,0 +1,36 @@ +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; +import { reactive } from "@odoo/owl"; +import { memoize } from "@web/core/utils/functions"; + + +export const statisticsService = { + + start() { + const stats = reactive({ + nb_new_orders: 0, + total_amount: 0, + average_quantity: 0, + nb_cancelled_orders: 0, + average_time: 0, + orders_by_size: { m: 0, s: 0, xl: 0 } + }); + + const loadStatistics = memoize(async () => { + const result = await rpc("/awesome_dashboard/statistics", {}); + if (result) { + Object.assign(stats, result); + } + }); + + loadStatistics(); + + setInterval(() => { + loadStatistics(); + }, 1000); + + return { data: stats }; + }, +}; + +registry.category("services").add("awesome_dashboard.statistics", statisticsService); \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/setting/setting_dialog.js b/awesome_dashboard/static/src/dashboard/setting/setting_dialog.js new file mode 100644 index 00000000000..3fb6dec11a9 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/setting/setting_dialog.js @@ -0,0 +1,35 @@ +import { Component, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { Dialog } from "@web/core/dialog/dialog"; + +export class SettingDialog extends Component { + static template = "awesome_dashboard.SettingDialog"; + static components = { Dialog }; + static props = { + close: { type: Function, optional: false }, + onApply: { type: Function, optional: true }, + }; + + setup() { + this.items = registry.category("awesome_dashboard.items").getAll(); + const hiddenItems = JSON.parse(localStorage.getItem("awesome_dashboard_hidden_items")) || []; + this.state = useState({ + items: this.items.map(item => ({ + id: item.id, + description: item.description, + enabled: !hiddenItems.includes(item.id), + })), + }); + } + + applySettings() { + const uncheckedItemIds = this.state.items + .filter(item => !item.enabled) + .map(item => item.id); + + if (this.props.onApply) { + this.props.onApply(uncheckedItemIds); + } + this.props.close(); + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/setting/setting_dialog.xml b/awesome_dashboard/static/src/dashboard/setting/setting_dialog.xml new file mode 100644 index 00000000000..f4d3eddd9e0 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/setting/setting_dialog.xml @@ -0,0 +1,21 @@ + + + + +
+

Which cards do you want to show?

+ +
  • + + +
  • +
    +
    + + + +
    +
    +
    \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard_action.js b/awesome_dashboard/static/src/dashboard_action.js new file mode 100644 index 00000000000..0f922763efa --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_action.js @@ -0,0 +1,11 @@ +import { Component } from "@odoo/owl"; +import { LazyComponent } from "@web/core/assets"; +import { registry } from "@web/core/registry"; + + +export class DashboardLoader extends Component { + static components = { LazyComponent }; + static template = "awesome_dashboard.DashboardLoader" +} + +registry.category("actions").add("awesome_dashboard.dashboard_action", DashboardLoader); \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard_action.xml b/awesome_dashboard/static/src/dashboard_action.xml new file mode 100644 index 00000000000..1c626a8e8d6 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_action.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/awesome_dashboard/views/views.xml b/awesome_dashboard/views/views.xml index 47fb2b6f258..1729ef86951 100644 --- a/awesome_dashboard/views/views.xml +++ b/awesome_dashboard/views/views.xml @@ -2,7 +2,7 @@ Dashboard - awesome_dashboard.dashboard + awesome_dashboard.dashboard_action 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..82e746d11b4 --- /dev/null +++ b/awesome_owl/static/src/components/card/card.js @@ -0,0 +1,22 @@ +import { Component, useState } from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.card"; + static props = { + title: String, + slots: { + type: Object, + shape: { + default: true + }, + } + }; + + setup() { + this.state = useState({ isOpen: true }); + } + + toggleContent() { + this.state.isOpen = !this.state.isOpen; + } +} \ No newline at end of file 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..4cd71d6575f --- /dev/null +++ b/awesome_owl/static/src/components/card/card.xml @@ -0,0 +1,19 @@ + + + +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    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..b8739b6a95a --- /dev/null +++ b/awesome_owl/static/src/components/counter/counter.js @@ -0,0 +1,28 @@ +// static/src/components/counter.js +import { Component, useState } from "@odoo/owl"; + +export class Counter extends Component { +static template = "component_counter"; +static props = { + title: String, + onChange: { type: Function, optional: true }, +}; + + setup() { + this.state = useState({ value: 1 }); + } + + increment() { + this.state.value++; + if (this.props.onChange) { + this.props.onChange(this.state.value); + } + } + + decrement() { + this.state.value--; + if (this.props.onChange) { + 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..bdff23ca634 --- /dev/null +++ b/awesome_owl/static/src/components/counter/counter.xml @@ -0,0 +1,26 @@ + + + +
    +
    +

    +
    +

    + Value: + +

    +
    + + +
    +
    +
    +
    \ No newline at end of file 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..2ccf27215f5 --- /dev/null +++ b/awesome_owl/static/src/components/playground/playground.js @@ -0,0 +1,42 @@ +/** @odoo-module **/ + +import { Component, markup, useState } from "@odoo/owl"; +import { Counter } from "../counter/counter"; +import { Card } from "../card/card"; +import { TodoList } from "../todolist/todo_list"; + +export class Playground extends Component { + static template = "awesome_owl.playground"; + static components = {Counter, Card, TodoList} + + setup(){ + this.state = useState({ + counter1: 1, + counter2: 1, + sum:2, + }); + } + + calculateSum=(counterName, value)=>{ + this.state[counterName]=value + this.state.sum = this.state.counter1 + this.state.counter2; + } + + cards = [ + { + id:1, + title: "Card 1", + content: "Just a simple text (escaped by default)", + }, + { + id:2, + title: "Card 2", + content: markup("Bold HTML content"), + }, + { + id:3, + title: "Card 3", + content: markup("
    Red colored HTML
    "), + }, + ]; +} 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..70665b78594 --- /dev/null +++ b/awesome_owl/static/src/components/playground/playground.xml @@ -0,0 +1,65 @@ + + + +
    + + +
    +

    🧮 Owl Counter Playground

    +
    + + +
    +

    + The sum is: + +

    +
    + + +
    +

    🃏 Dynamic Cards (Loop)

    +
    + + +

    +
    +
    +
    +
    + + +
    +

    🧩 Custom Cards with Slots

    +
    + + + + + +
    +
    Hello Owl 🦉!
    +

    This is a card with custom slot content.

    +
    +
    + + +
      +
    • ✅ Uses Bootstrap styling
    • +
    • 🎨 Supports any component inside
    • +
    • 🧩 Built using <t-slot>
    • +
    +
    +
    +
    + + +
    +

    ✅ Your Task List

    +
    + +
    +
    +
    +
    +
    diff --git a/awesome_owl/static/src/components/todolist/todo_item.js b/awesome_owl/static/src/components/todolist/todo_item.js new file mode 100644 index 00000000000..b48a4e6eba6 --- /dev/null +++ b/awesome_owl/static/src/components/todolist/todo_item.js @@ -0,0 +1,23 @@ +import { Component } 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 + } + }, + toggleState: Function, + removeTodo: Function, + }; + onCheckboxChange() { + this.props.toggleState(this.props.todo.id); + } + onRemoveClick() { + this.props.removeTodo(this.props.todo.id); + } +} diff --git a/awesome_owl/static/src/components/todolist/todo_item.xml b/awesome_owl/static/src/components/todolist/todo_item.xml new file mode 100644 index 00000000000..471f448ccf9 --- /dev/null +++ b/awesome_owl/static/src/components/todolist/todo_item.xml @@ -0,0 +1,27 @@ + + + +
    + +
    + +
    + +
    +
    + + +
    +
    +
    diff --git a/awesome_owl/static/src/components/todolist/todo_list.js b/awesome_owl/static/src/components/todolist/todo_list.js new file mode 100644 index 00000000000..b0bdfb7c67b --- /dev/null +++ b/awesome_owl/static/src/components/todolist/todo_list.js @@ -0,0 +1,49 @@ +import { Component, useState, useRef } from "@odoo/owl"; +import { TodoItem } from "./todo_item"; +import { useAutofocus } from "../../utils"; + +export class TodoList extends Component { + static template = "awesome_owl.todoList"; + static components = { TodoItem}; + + setup() { + this.todos = useState([]); + this.nextId = 1; + useAutofocus("input") + // this.toggleTodo = this.toggleTodo.bind(this); + } + + addTodo(item){ + if(item.key === 'Enter'){ + const input = item.target; + const description = input.value.trim(); + + if(!description){ + return; + } + else{ + this.todos.push({ + id: this.nextId++, + description, + isCompleted : false + }); + input.value = "" + } + } + } + + toggleTodo=(id)=>{ + const todo = this.todos.find(prev => prev.id === id); + if(todo){ + todo.isCompleted = !todo.isCompleted + } + } + + removeTodoItem=(todoId)=> { + const index = this.todos.findIndex(todo => todo.id === todoId); + if (index >= 0) { + this.todos.splice(index, 1); + } + } + +} \ No newline at end of file diff --git a/awesome_owl/static/src/components/todolist/todo_list.xml b/awesome_owl/static/src/components/todolist/todo_list.xml new file mode 100644 index 00000000000..dfaede7f0ed --- /dev/null +++ b/awesome_owl/static/src/components/todolist/todo_list.xml @@ -0,0 +1,36 @@ + + + +
    + + +
    +

    📝 Todo List

    + + Tasks + +
    + + +
    + +
    + + +
    + +

    No tasks yet. Add one above ⬆️

    +
    + + + +
    +
    +
    +
    diff --git a/awesome_owl/static/src/main.js b/awesome_owl/static/src/main.js index 1af6c827e0b..7f8ec6badf2 100644 --- a/awesome_owl/static/src/main.js +++ b/awesome_owl/static/src/main.js @@ -1,6 +1,8 @@ import { whenReady } from "@odoo/owl"; import { mountComponent } from "@web/env"; -import { Playground } from "./playground"; +import { Playground } from "./components/playground/playground"; +import "@web/core/assets"; + 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..8a56d82c8bf --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,9 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export function useAutofocus(refName) { + const ref = useRef(refName); + onMounted(() => { + ref.el?.focus(); + }); + return ref; +} diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..48d9904390f --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import controllers +from . import wizard diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..a0a3bbc3583 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,30 @@ +{ + 'name': 'Real Estate', + 'version': '1.0', + 'depends': ['mail', 'website'], + 'author': 'Rajeev Aanjana', + 'category': 'Real Estate/Brokerage', + 'description': 'A module for managing real estate properties', + 'application': True, + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'wizard/estate_property_offer_wizard.xml', + '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', + 'views/estate_property_templates.xml', + # 'data/master_data.xml', + 'data/estate_property_demo.xml', + 'report/estate_property_templates.xml', + 'report/estate_property_reports.xml', + ], + # 'demo': [ + # 'demo/demo_data.xml', + # ], + 'license': 'LGPL-3', + 'installable': True, +} diff --git a/estate/controllers/__init__.py b/estate/controllers/__init__.py new file mode 100644 index 00000000000..12a7e529b67 --- /dev/null +++ b/estate/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/estate/controllers/main.py b/estate/controllers/main.py new file mode 100644 index 00000000000..791d9744faa --- /dev/null +++ b/estate/controllers/main.py @@ -0,0 +1,40 @@ +from odoo import http +from odoo.http import request + + +class EstateWebsiteController(http.Controller): + + @http.route('/properties', type='http', auth='public', website=True) + def list_properties(self, min_price=0, max_price=0, **kwargs): + domain = [] + # try: + # min_price = float(min_price) + # except (ValueError, TypeError): + # min_price = 0 + # try: + # max_price = float(max_price) + # except (ValueError, TypeError): + # max_price = 0 + + if min_price: + domain.append(('selling_price', '>=', min_price)) + if max_price: + domain.append(('selling_price', '<=', max_price)) + + properties = request.env['estate.property'].sudo().search(domain) + + return request.render('estate.property_listing', { + 'properties': properties, + 'min_price': min_price, + 'max_price': max_price, + }) + + @http.route('/properties/', type='http', auth='public', website=True) + def property_detail(self, property_id, **kwargs): + property_rec = request.env['estate.property'].sudo().browse(property_id) + if not property_rec.exists(): + return request.not_found() + + return request.render('estate.property_detail', { + 'property': property_rec + }) diff --git a/estate/data/estate_property_demo.xml b/estate/data/estate_property_demo.xml new file mode 100644 index 00000000000..635bd7a720f --- /dev/null +++ b/estate/data/estate_property_demo.xml @@ -0,0 +1,125 @@ + + + Residential + + + Commercial + + + Industrial + + + Land + + + + + + Big Villa + new + A nice and big villa + 12345 + 2020-02-02 + 1600000 + 6 + 100 + 4 + True + True + 100000 + south + + + + + + 10000 + 14 + + + + + + 1500000 + 14 + + + + + + 1500001 + 14 + + + + + + Trailer Home + canceled + Home in a trailer park + 54321 + 1970-01-01 + 100000 + 120000 + 1 + 10 + 4 + False + True + + + + + International Space Station + new + Aliens sometimes come visit + 12345 + 2030-12-31 + 45890000 + + + + + Cozy Cabin + new + Small cabin by lake + 10000 + 2020-01-01 + 80000 + 1 + 10 + 4 + False + True + + + + + 60000 + 14 + + + + + + 75000 + 14 + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/estate/data/master_data.xml b/estate/data/master_data.xml new file mode 100644 index 00000000000..506df5750d1 --- /dev/null +++ b/estate/data/master_data.xml @@ -0,0 +1,14 @@ + + + Residential + + + Commercial + + + Industrial + + + Land + + diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..0103b221de9 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,7 @@ +from . import ( + estate_property, + estate_property_type, + estate_property_offer, + estate_property_tag, + res_users, +) diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..1d04c95fec7 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,147 @@ +from dateutil.relativedelta import relativedelta + +from odoo import fields, models, api +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 = "Real Estate Property" + _order = "id desc" + _inherit = ['mail.thread', 'mail.activity.mixin'] + # Basic Fields + name = fields.Char(string="Name", required=True) + description = fields.Text(string="Description") + postcode = fields.Char(string="Postcode") + # Date Fields + date_availability = fields.Date( + string="Available From", + copy=False, + default=lambda self: fields.Date.today() + relativedelta(months=3) + ) + # Price Fields + expected_price = fields.Float(string="Expected Price", required=True) + selling_price = fields.Float(string="Selling Price", readonly=True, copy=False) + # Property Details + bedrooms = fields.Integer(string="Bedrooms", default=2) + living_area = fields.Float(string="Living Area (sqm)") + facades = fields.Integer(string="Facades") + garage = fields.Boolean(string="Garage") + garden = fields.Boolean(string="Garden") + garden_area = fields.Float(string="Garden Area (sqm)") + best_price = fields.Float("Best Offer", compute="_compute_best_price") + # Selection Fields + garden_orientation = fields.Selection([('north', 'North'), ('south', 'South'), ('east', 'East'), ('west', 'West')], string='Garden Orientation') + state = fields.Selection( + [ + ("new", "New"), + ("offer_received", "Offer Received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("canceled", "Canceled"), + ], + string="Status", + required=True, + copy=False, + default="new", + ) + active = fields.Boolean(string="Active", default=True) + # Many2one, Many2many, One2many Relation to Property Type + property_type_id = fields.Many2one('estate.property.type', string="Property Type", required=True) + buyer_id = fields.Many2one("res.partner", string="Buyer", copy=False) + salesperson_id = fields.Many2one( + "res.users", + string="Sales Person", + default=lambda self: self.env.user, + required=True, + ) + offer_ids = fields.One2many( + "estate.property.offer", + "property_id", + string="Offers" + ) + tag_ids = fields.Many2many("estate.property.tag", string="Tags") + total_area = fields.Float(string='Total Area (sqm)', compute='_compute_total_area', help='Sum of living area and garden area') + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company + ) + # SQL Constraints + _sql_constraints = [ + ( + "check_expected_price_positive", + "CHECK(expected_price >= 0)", + "The expected price must be strictly positive.", + ), + ( + "check_selling_price_positive", + "CHECK(selling_price >= 0)", + "The selling price must be strictly positive.", + ), + ( + "check_bedrooms_positive", + "CHECK(bedrooms >= 0)", + "The number of bedrooms must be zero or positive.", + ), + ] + # Computed Fields & Onchange + + @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("offer_ids.offer_price") + def _compute_best_price(self): + for record in self: + record.best_price = max(record.offer_ids.mapped("offer_price"), default=0.0) + + # Onchange + @api.onchange('garden') + def _onchange_garden(self): + for record in self: + if record.garden: + record.garden_area = 10 + record.garden_orientation = 'north' + else: + record.garden_area = 0 + record.garden_orientation = False + + # Add Action Logic of "Cancel" & "Sold" + def action_set_sold(self): + for record in self: + if record.state == "canceled": + raise UserError("A cancelled property cannot be sold.") + + if record.state != "offer_accepted": + raise UserError( + "You cannot mark a property as sold without accepting an offer." + ) + + record.state = "sold" + + return True + + def action_set_canceled(self): + for record in self: + if record.state == "sold": + raise UserError("Sold property cannot be canceled.") + record.state = "canceled" + + @api.constrains('selling_price', 'expected_price') + def _check_selling_price(self): + 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 cannot be lower than 90% of the expected price.") + + @api.ondelete(at_uninstall=False) + def _check_state_before_delete(self): + for record in self: + if record.state not in ('new', 'canceled'): + raise UserError("You can only delete properties in 'New' or 'canceled' state.") diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..0714f79a7f9 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,102 @@ +from datetime import timedelta + +from odoo import api, models, fields +from odoo.exceptions import UserError, ValidationError + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Real Estate Property Offer" + _order = "offer_price desc" + + offer_price = fields.Float(string="Price") + status = fields.Selection( + [("accepted", "Accepted"), ("refused", "Refused")], string="Status", copy=False + ) + partner_id = fields.Many2one("res.partner", string="Buyer", required=True) + property_id = fields.Many2one( + "estate.property", string="Property", required=True, ondelete="cascade" + ) + validity = fields.Integer(string="Validity (days)", default=7) + date_deadline = fields.Date( + string="Date Deadline", + compute="_compute_date_deadline", + inverse="_inverse_date_deadline", + ) + property_type_id = fields.Many2one( + related="property_id.property_type_id", store=True + ) + + _sql_constraints = [ + ( + "check_offer_price_positive", + "CHECK(offer_price > 0)", + "The offer price must be strictly positive.", + ), + ] + + @api.depends("validity") + def _compute_date_deadline(self): + for record in self: + create_date = record.create_date or fields.Date.context_today(record) + record.date_deadline = create_date + timedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + create_date = record.create_date or fields.Datetime.now() + delta = record.date_deadline - create_date.date() + record.validity = delta.days + + def action_accept(self): + for record in self: + if record.property_id.state in ["sold", "canceled"]: + raise UserError( + "You cannot accept an offer for a sold or cancelled property." + ) + # if record.property_id.offer_ids.filtered(lambda o: o.status == "accepted"): + # raise UserError("An offer has already been accepted for this property.") + if record.property_id.state == 'offer_accepted': + raise UserError("An offer has already been accepted for this property.") + + record.status = "accepted" + record.property_id.buyer_id = record.partner_id + record.property_id.selling_price = record.offer_price + record.property_id.state = "offer_accepted" + + def action_refuse(self): + for record in self: + record.status = "refused" + + @api.model_create_multi + def create(self, offers): + # Guard clause for empty input + if not offers: + return super().create(offers) + + # Extract property_id from the first offer + property_id = offers[0].get("property_id") + if not property_id: + raise ValidationError("Property ID is required.") + + # Fetch the related property record + estate = self.env["estate.property"].browse(property_id) + if not estate.exists(): + raise ValidationError("The specified property does not exist.") + + # Business rules + if estate.state in ["sold", "canceled"]: + raise UserError("Cannot create an offer on a sold or canceled property.") + if estate.state == "offer_accepted": + raise UserError("Cannot create an offer on a property with an accepted offer.") + + # Offer price validation + curr_max_price = estate.best_price or 0.0 + for offer in offers: + if curr_max_price >= offer["offer_price"]: + raise UserError("The offer price must be higher than the current best price.") + curr_max_price = max(curr_max_price, offer["offer_price"]) + + # Update property state + estate.state = "offer_received" + + return super().create(offers) diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..edc6fc0fff4 --- /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 Property Tag" + _order = "name asc" + + name = fields.Char(string='Name', required=True) + color = fields.Integer(string="Color") + # SQL Constraints + _sql_constraints = [ + ('unique_tag_name', 'UNIQUE(name)', 'The 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..2b05ee685ca --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,30 @@ +from odoo import api, fields, models + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Real Estate Property Type" + _order = "sequence, name" + + sequence = fields.Integer(string="Sequence") + # Basic Fields + name = fields.Char(required=True) + status = fields.Selection([('active', 'Active'), ('inactive', 'Inactive')], string='Status', default='active', required=True) + description = fields.Text(string="Description") + # One2Many + 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(string='Number of Offers', compute='_compute_offer_count') + # SQL Constraints + _sql_constraints = [ + ('unique_type_name', 'UNIQUE(name)', 'The property type name must be unique.'), + ] + + @api.depends("property_ids.offer_ids") + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..964cf103b75 --- /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', + 'salesperson_id', + string='Properties', + # domain=[('state', '=', 'available')] + ) diff --git a/estate/report/estate_property_reports.xml b/estate/report/estate_property_reports.xml new file mode 100644 index 00000000000..6e55125ad11 --- /dev/null +++ b/estate/report/estate_property_reports.xml @@ -0,0 +1,21 @@ + + + Estate Property Offers Report + estate.property + qweb-pdf + estate.estate_property_report_offers + 'Property Offer Report - %s' % object.name + + + + + + Salesperson Properties Report + res.users + qweb-pdf + estate.res_users_report_properties + 'Salesperson Properties Report - %s' % object.name + + + + diff --git a/estate/report/estate_property_templates.xml b/estate/report/estate_property_templates.xml new file mode 100644 index 00000000000..051d86733b9 --- /dev/null +++ b/estate/report/estate_property_templates.xml @@ -0,0 +1,99 @@ + + + + + + + diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..caa76898747 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,13 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + +estate.access_estate_property_user,access_estate_property_user,estate.model_estate_property,estate.estate_group_user,1,1,1,0 + +estate.access_estate_property_offer_user,access_estate_property_offer_user,estate.model_estate_property_offer,estate.estate_group_user,1,1,1,0 + +estate.access_estate_property_type_user,access_estate_property_type_user,estate.model_estate_property_type,estate.estate_group_user,1,0,0,0 +estate.access_estate_property_type_manager,access_estate_property_type_manager,estate.model_estate_property_type,estate.estate_group_manager,1,1,1,0 + +estate.access_estate_property_tag_user,access_estate_property_tag_user,estate.model_estate_property_tag,estate.estate_group_user,1,0,0,0 +estate.access_estate_property_tag_manager,access_estate_property_tag_manager,estate.model_estate_property_tag,estate.estate_group_manager,1,1,1,0 + +estate.access_estate_property_offer_wizard_user,access_estate_property_offer_wizard_user,estate.model_estate_property_offer_wizard,estate.estate_group_user,1,1,1,0 \ No newline at end of file diff --git a/estate/security/security.xml b/estate/security/security.xml new file mode 100644 index 00000000000..545ead76a5a --- /dev/null +++ b/estate/security/security.xml @@ -0,0 +1,36 @@ + + + Agent + A Real Estate Agent + + + + + Manager + A Real Estate Manager + + + + + + + Salesperson: Access to Own or Unassigned Properties + + + ['|', ('salesperson_id', '=', user.id), ('salesperson_id', '=', False)] + + + + Manager: Full Access to All Properties + + + + + + + Company Isolation: Access Limited to User's Company Properties + + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py new file mode 100644 index 00000000000..576617cccff --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1 @@ +from . import test_estate_property diff --git a/estate/tests/test_estate_property.py b/estate/tests/test_estate_property.py new file mode 100644 index 00000000000..6cd509d6d09 --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,103 @@ +from odoo import Command +from odoo.exceptions import UserError +from odoo.tests import tagged, Form +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class EstateTestCase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.property_type = cls.env["estate.property.type"].create({"name": "House Test Type"}) + + cls.properties = cls.env["estate.property"].create( + [ + { + "name": "Sale Test Property", + "description": "Test Description", + "expected_price": 100000, + "living_area": 50, + "property_type_id": cls.property_type.id, + }, + { + "name": "Garden Test Property", + "description": "Test Description Garden", + "expected_price": 200000, + "living_area": 100, + "property_type_id": cls.property_type.id, + }, + ] + ) + + cls.offers = cls.env["estate.property.offer"].create( + [ + { + "partner_id": cls.env.ref("base.res_partner_2").id, + "offer_price": 110000, + "property_id": cls.properties[0].id, + }, + { + "partner_id": cls.env.ref("base.res_partner_12").id, + "offer_price": 130000, + "property_id": cls.properties[0].id, + }, + { + "partner_id": cls.env.ref("base.res_partner_2").id, + "offer_price": 150000, + "property_id": cls.properties[0].id, + }, + ] + ) + + def test_sell_property_without_accepted_offer(self): + """ + Test selling a property without an accepted offer. + Ensure that a UserError is raised when trying to sell a property without an accepted offer. + Ensure that other offers are not allowed to be created after the property is sold. + """ + + with self.assertRaises(UserError): + self.properties[0].action_set_sold() + + self.offers[1].action_accept() + self.properties[0].action_set_sold() + + self.assertEqual( + self.properties[0].state, "sold", "Property was not marked as sold" + ) + + with self.assertRaises(UserError): + self.properties[0].offer_ids = [ + Command.create( + { + "partner_id": self.env.ref("base.res_partner_2").id, + "price": 200000, + "property_id": self.properties[0].id, + } + ) + ] + + def test_garden_toggle(self): + """ + Test toggling the garden field on the property. + Ensure that the garden area and orientation are resetting. + """ + + with Form(self.properties[1]) as form: + form.garden = True + self.assertEqual(form.garden_area, 10, "Garden area should be reset to 10") + self.assertEqual( + form.garden_orientation, + "north", + "Garden orientation should be reset to north", + ) + + form.garden = False + self.assertEqual(form.garden_area, 0, "Garden area should be reset to 0") + self.assertEqual( + form.garden_orientation, + False, + "Garden orientation should be reset to False", + ) diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..ade5a0aa7c6 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..059667293c3 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,43 @@ + + + Property Offers + estate.property.offer + list,form + [('property_type_id', '=', active_id)] + + + + estate.property.offer.list + estate.property.offer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.type.search + 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..7ec2373e42b --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,203 @@ + + + Properties + estate.property + list,form,kanban + {'search_default_available_property_filter': True} + +

    + Properties +

    +

    + Create your properties here. +

    +
    +
    + + + Open External Website + /properties + new + + + + + estate.property.list + estate.property + + +
    +
    + + + + + + + + + + + + + +
    +
    +
    + + + + estate.property.form + estate.property + +
    +
    + +
    + +
    +

    + +

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + +
    +
    + + + + + estate.property.view.kanban + estate.property + + + + + +
    +
    + +
    + +
    +
    +
    + Expected Price : +
    +
    + Best Price : +
    +
    + Selling Price: +
    +
    +
    +
    +
    +
    +
    + + + Estate Create Form + estate.property + 17 + +
    + + + + + +
    +
    +
    + + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + +
    diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..d0845d551ed --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,14 @@ + + + inherited.user.view.form + res.users + + + + + + + + + + diff --git a/estate/wizard/__init__.py b/estate/wizard/__init__.py new file mode 100644 index 00000000000..e9926bcd3ec --- /dev/null +++ b/estate/wizard/__init__.py @@ -0,0 +1 @@ +from . import estate_property_offer_wizard diff --git a/estate/wizard/estate_property_offer_wizard.py b/estate/wizard/estate_property_offer_wizard.py new file mode 100644 index 00000000000..6b7bf1b8a60 --- /dev/null +++ b/estate/wizard/estate_property_offer_wizard.py @@ -0,0 +1,35 @@ +from odoo import api, fields, models + + +class MakeOfferWizard(models.TransientModel): + _name = 'estate.property.offer.wizard' + _description = 'Property Offer Wizard' + + offer_price = fields.Float('Offer Price', required=True) + status = fields.Selection( + [('accepted', 'Accepted'), ('refused', 'Refused')], + string='Status' + ) + partner_id = fields.Many2one('res.partner', 'Buyer', required=True) + property_ids = fields.Many2many( + 'estate.property', + string='Selected Properties', + ) + + @api.model + def default_get(self, fields): + res = super().default_get(fields) + property_ids = self.env.context.get("active_ids", []) + if property_ids: + res["property_ids"] = [(6, 0, property_ids)] + return res + + def action_make_offer(self): + for property in self.property_ids: + self.env['estate.property.offer'].create({ + "offer_price": self.offer_price, + 'status': self.status, + 'partner_id': self.partner_id.id, + 'property_id': property.id, + }) + return {'type': 'ir.actions.act_window_close'} diff --git a/estate/wizard/estate_property_offer_wizard.xml b/estate/wizard/estate_property_offer_wizard.xml new file mode 100644 index 00000000000..6e0b4172d50 --- /dev/null +++ b/estate/wizard/estate_property_offer_wizard.xml @@ -0,0 +1,28 @@ + + + Add Offer + estate.property.offer.wizard + form + new + + list + + + + estate.property.offer.wizard.form + estate.property.offer.wizard + +
    + + + + + +
    +
    +
    +
    +
    +
    \ No newline at end of file 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..0624b0016ee --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,15 @@ +{ + 'name': 'Real Estate Account', + 'version': '1.0', + 'summary': 'Link between Real Estate and Accounting', + 'description': """ + This module links the Real Estate module with Accounting, + automatically creating invoices when properties are sold. + """, + 'depends': ['estate', 'account'], + "category": "Sales", + '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..93b13d1fc71 --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1 @@ +from . import inherited_estate_property diff --git a/estate_account/models/inherited_estate_property.py b/estate_account/models/inherited_estate_property.py new file mode 100644 index 00000000000..83f31fb382b --- /dev/null +++ b/estate_account/models/inherited_estate_property.py @@ -0,0 +1,32 @@ +from datetime import datetime +from odoo import models, Command + + +class EstateModel(models.Model): + _inherit = "estate.property" + + def action_set_sold(self): + self.check_access("write") + if super().action_set_sold() is True: + invoice_vals = self._prepare_invoice() + self.env["account.move"].sudo().create(invoice_vals) + + def _prepare_invoice(self): + """Prepare invoice vals with strict field control""" + return { + "partner_id": self.buyer_id.id, + "move_type": "out_invoice", + "invoice_date": datetime.today(), + "invoice_line_ids": [ + Command.create({ + "name": f"Commission for {self.name}", + "quantity": 1, + "price_unit": (self.selling_price * 0.06), # 6% commission + }), + Command.create({ + "name": "Administrative Fees", + "quantity": 1, + "price_unit": 100.00, # Fixed fee + }), + ], + } diff --git a/rental_deposit/__init__.py b/rental_deposit/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/rental_deposit/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/rental_deposit/__manifest__.py b/rental_deposit/__manifest__.py new file mode 100644 index 00000000000..1624de3036e --- /dev/null +++ b/rental_deposit/__manifest__.py @@ -0,0 +1,18 @@ +{ + 'name': 'Rental Deposit', + 'version': '1.0', + 'depends': ['sale_renting', 'website_sale'], + 'category': 'Sales', + 'Summary': 'Add deposit logic to rental products on sale order and webshop', + 'data': [ + 'views/res_config_settings_views.xml', + 'views/product_template_views.xml', + 'views/product_webiste_template_views.xml', + ], + 'assets': { + 'web.assets_frontend': { + 'deposit_rental/static/src/website_deposit_amount.js', + } + }, + 'license': 'LGPL-3', +} diff --git a/rental_deposit/models/__init__.py b/rental_deposit/models/__init__.py new file mode 100644 index 00000000000..d5e3c47b5c4 --- /dev/null +++ b/rental_deposit/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_config_settings +from . import product_template +from . import sale_order +from . import sale_order_line diff --git a/rental_deposit/models/product_template.py b/rental_deposit/models/product_template.py new file mode 100644 index 00000000000..1057bb0f585 --- /dev/null +++ b/rental_deposit/models/product_template.py @@ -0,0 +1,13 @@ +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + deposit_require = fields.Boolean(string='Require Deposit') + deposit_amount = fields.Monetary(string='Deposit Amount') + currency_id = fields.Many2one( + 'res.currency', string='Currency', + required=True, + default=lambda self: self.env.company.currency_id, + ) diff --git a/rental_deposit/models/res_config_settings.py b/rental_deposit/models/res_config_settings.py new file mode 100644 index 00000000000..c62f2d9ee94 --- /dev/null +++ b/rental_deposit/models/res_config_settings.py @@ -0,0 +1,27 @@ +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + deposit_product = fields.Many2one('product.product', string='Deposit Product') + + def set_values(self): + super().set_values() + # Save the product ID or 0 if no product is selected + self.env['ir.config_parameter'].sudo().set_param( + 'rental_deposit.deposit_product', + self.deposit_product.id if self.deposit_product else 0 + ) + + @api.model + def get_values(self): + res = super().get_values() + # Read the deposit product ID as string + config_value = self.env['ir.config_parameter'].sudo().get_param( + 'rental_deposit.deposit_product', default=0 + ) + # Convert to int and browse the product; if empty or invalid, return empty recordset + product = self.env['product.product'].browse(int(config_value)) if config_value else self.env['product.product'] + res.update(deposit_product=product) + return res diff --git a/rental_deposit/models/sale_order.py b/rental_deposit/models/sale_order.py new file mode 100644 index 00000000000..a8cc00023cb --- /dev/null +++ b/rental_deposit/models/sale_order.py @@ -0,0 +1,35 @@ +from odoo import _, models +from odoo.exceptions import UserError + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + def _add_deposit_product(self, order_line): + config_product = int(self.env['ir.config_parameter'].sudo().get_param('rental_deposit.deposit_product', default=0)) + if not config_product: + raise UserError(_("No deposit product configured. Please configure it in Rental Settings.")) + + deposit_product = self.env['product.product'].browse(config_product) + if not deposit_product.exists(): + raise UserError(_("The configured deposit product does not exist.")) + + existing_line = self.order_line.filtered(lambda l: l.linked_line_id.id == order_line.id) + # amount = order_line.product_id.deposit_amount * order_line.product_uom_qty + + if existing_line: + existing_line.write({ + 'product_uom_qty': order_line.product_uom_qty, + 'price_unit': order_line.product_id.deposit_amount, + 'name': f"Deposit for {order_line.product_id.display_name}" + }) + else: + self.env['sale.order.line'].create({ + 'order_id': self.id, + 'product_id': deposit_product.id, + 'product_uom_qty': order_line.product_uom_qty, + 'product_uom': deposit_product.uom_id.id, + 'price_unit': order_line.product_id.deposit_amount, + 'linked_line_id': order_line.id, + 'name': f"Deposit for {order_line.product_id.display_name}", + }) diff --git a/rental_deposit/models/sale_order_line.py b/rental_deposit/models/sale_order_line.py new file mode 100644 index 00000000000..04dc267a381 --- /dev/null +++ b/rental_deposit/models/sale_order_line.py @@ -0,0 +1,23 @@ +from odoo import api, fields, models + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + linked_line_id = fields.Many2one('sale.order.line', string='Linked Product Line') + + @api.model_create_multi + def create(self, vals): + lines = super().create(vals) + for line in lines: + deposit_product_id = int(self.env['ir.config_parameter'].sudo().get_param('rental_deposit.deposit_product', default=0)) + if line.product_id.id != deposit_product_id and line.product_id.rent_ok and line.product_id.deposit_require: + line.order_id._add_deposit_product(line) + return lines + + def write(self, vals): + res = super().write(vals) + for line in self: + if 'product_uom_qty' in vals and line.product_id.deposit_require: + line.order_id._add_deposit_product(line) + return res diff --git a/rental_deposit/static/src/website_deposit_amount.js b/rental_deposit/static/src/website_deposit_amount.js new file mode 100644 index 00000000000..7da3e3a25b8 --- /dev/null +++ b/rental_deposit/static/src/website_deposit_amount.js @@ -0,0 +1,21 @@ +import publicWidget from "@web/legacy/js/public/public_widget"; + +publicWidget.registry.DepositRental = publicWidget.Widget.extend({ + selector: "#product_detail", + events: { + 'change input[name="add_qty"]': '_updateDepositAmount', + }, + start: function () { + this._super.apply(this, arguments); + if ($("#deposit_amount").length && $("#deposit_amount").data("base-amount") > 0) { + this._updateDepositAmount(); + } else { + this.$el.off('change input[name="add_qty"]'); + } + }, + _updateDepositAmount: function () { + var qty = parseFloat($("#o_wsale_cta_wrapper").find("input[name='add_qty']").val()) || 1; + var depositAmount = parseFloat($("#deposit_amount").data("base-amount")) || 0; + $("#deposit_amount").text(depositAmount * qty); + } +}); diff --git a/rental_deposit/views/product_template_views.xml b/rental_deposit/views/product_template_views.xml new file mode 100644 index 00000000000..d4bc0d02481 --- /dev/null +++ b/rental_deposit/views/product_template_views.xml @@ -0,0 +1,15 @@ + + + product.template.form.deposit.rental + product.template + + + + + + + + + + + diff --git a/rental_deposit/views/product_webiste_template_views.xml b/rental_deposit/views/product_webiste_template_views.xml new file mode 100644 index 00000000000..2904abb0fb2 --- /dev/null +++ b/rental_deposit/views/product_webiste_template_views.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/rental_deposit/views/res_config_settings_views.xml b/rental_deposit/views/res_config_settings_views.xml new file mode 100644 index 00000000000..5fb87c6bd8a --- /dev/null +++ b/rental_deposit/views/res_config_settings_views.xml @@ -0,0 +1,16 @@ + + + + res.config.settings.deposit.rental + res.config.settings + + + +
    +
    +
    +
    +
    +