diff --git a/__manifest__.py b/__manifest__.py index 1de5461549e68e7c56b656bd79db2d26b9969cc0..f75be971bb87feec5dd45597a1e3c27ae68b22ce 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -8,7 +8,13 @@ "website": "https://le-filament.com", "version": "17.0.1.0.0", "license": "AGPL-3", - "depends": ["account_usability", "crm", "hr_expense", "sale"], + "depends": [ + "account_usability", + "crm", + "hr_expense", + "hr_holidays", + "lefilament_sales", + ], "data": [ # security "security/lefilament_dashboard_security.xml", @@ -21,4 +27,11 @@ # menus "views/menus.xml", ], + "assets": { + "web.assets_backend": [ + "lefilament_tdb/static/src/components/*.js", + "lefilament_tdb/static/src/components/*.xml", + "lefilament_tdb/static/src/components/lefilament_tdb.css", + ], + }, } diff --git a/models/lefilament_tdb.py b/models/lefilament_tdb.py index 8dc632617a918cb30290343680dceca3d50db070..5fceb8c5cf0c0ee25601b72f0079f96a796dd21b 100644 --- a/models/lefilament_tdb.py +++ b/models/lefilament_tdb.py @@ -4,9 +4,9 @@ from datetime import date from dateutil.relativedelta import relativedelta +from psycopg2._psycopg import AsIs from odoo import api, fields, models -from odoo.tools.safe_eval import safe_eval class LeFilamentTdb(models.Model): @@ -147,19 +147,6 @@ class LeFilamentTdb(models.Model): # ------------------------------------------------------ # Action buttons # ------------------------------------------------------ - @api.model - def open_detail(self, target_model, name=None, domain=None, views=None): - action = { - "name": name or target_model, - "type": "ir.actions.act_window", - "res_model": target_model, - } - if domain: - action["domain"] = safe_eval(domain) - if views: - action["views"] = safe_eval(views) - - return action # ------------------------------------------------------ # Business function @@ -167,3 +154,420 @@ class LeFilamentTdb(models.Model): def get_month_values(self): for data in self: data._compute_dashboard_values() + + @staticmethod + def _format_monetary(amount): + return f"{amount:,}".replace(",", " ") + " €" + + # @api.model + # def retrieve_datas_dashboard(self): + # # Get fiscal years + # fiscal_date = date( + # date.today().year, + # int(self.env.company.fiscalyear_last_month), + # int(self.env.company.fiscalyear_last_day), + # ) + # if date.today() > fiscal_date: + # fiscal_year = fiscal_date + # fiscal_year_next = fiscal_date + relativedelta(years=1) + # else: + # fiscal_year = fiscal_date - relativedelta(years=1) + # fiscal_year_next = fiscal_date + # + # # Invoices + # invoiced = ( + # self.env["account.move"] + # .read_group( + # [ + # ("move_type", "in", ("out_invoice", "out_refund")), + # ("state", "=", "posted"), + # ("invoice_date", ">", fiscal_year), + # ("invoice_date", "<=", fiscal_year_next), + # ], + # ["amount_untaxed_signed:sum"], + # [], + # )[0] + # .get("amount_untaxed_signed", 0.0) + # ) + # # Orders + # ordered = ( + # self.env["sale.order.line"] + # .read_group( + # [ + # ("invoice_status", "=", "to invoice"), + # ], + # ["price_subtotal:sum"], + # [], + # )[0] + # .get("price_subtotal", 0.0) + # ) + # # Leads + # pipe_win = ( + # self.env["crm.lead"] + # .read_group( + # [ + # ("probability", "=", 100), + # "|", + # ("date_deadline", "<=", fiscal_year_next), + # ("date_deadline", "=", False), + # ], + # ["prorated_revenue:sum"], + # [], + # )[0] + # .get("prorated_revenue", 0.0) + # ) + # pipe = ( + # self.env["crm.lead"] + # .read_group( + # [ + # "|", + # ("date_deadline", "<=", fiscal_year_next), + # ("date_deadline", "=", False), + # ], + # ["prorated_revenue:sum"], + # [], + # )[0] + # .get("prorated_revenue", 0.0) + # ) + # + # target = self.env.company.ca_target + # invoiced_percentage = round(invoiced / target * 100) if target else 0 + # ordered_percentage = round(ordered / target * 100) if target else 0 + # toinvoice = target - invoiced + # toinvoice_percentage = round(toinvoice / target * 100) if target else 0 + # pipe_win_percentage = round(pipe_win / target * 100) if target else 0 + # ongoing = invoiced + ordered + pipe_win + # ongoing_percentage = ( + # round((invoiced + ordered + pipe_win) / target * 100) if target else 0 + # ) + # + # to_get = ( + # self.env["account.move"] + # .read_group( + # [ + # ("move_type", "in", ("out_invoice", "out_refund")), + # ("state", "=", "posted"), + # ("payment_state", "in", ["not_paid", "partial", "in_payment"]), + # ], + # ["amount_untaxed_signed:sum"], + # [], + # )[0] + # .get("amount_untaxed_signed", 0.0) + # ) + # to_pay = ( + # self.env["account.move"] + # .read_group( + # [ + # ("move_type", "in", ("in_invoice", "in_refund")), + # ("state", "=", "posted"), + # ("payment_state", "in", ["not_paid", "partial", "in_payment"]), + # ], + # ["amount_untaxed_signed:sum"], + # [], + # )[0] + # .get("amount_untaxed_signed", 0.0) + # ) + # + # date_maj = ( + # self.env["account.bank.statement.line"] + # .search([], order="date desc", limit=1) + # .date.strftime("%d/%m/%Y") + # ) + # available_cash = ( + # self.env["account.bank.statement.line"] + # .read_group([], ["amount:sum"], [])[0] + # .get("amount", 0.0) + # ) + # cash_by_bank = self.env["account.bank.statement.line"].read_group( + # domain=[], + # fields=["journal_id", "amount"], + # groupby=["journal_id"], + # orderby="journal_id", + # ) + # + # cash_in = ( + # self.env["account.bank.statement.line"] + # .read_group( + # [ + # ("amount", ">", 0), + # ("date", ">", fiscal_year), + # ], + # ["amount:sum"], + # [], + # )[0] + # .get("amount", 0.0) + # ) + # cash_out = ( + # self.env["account.bank.statement.line"] + # .read_group( + # [ + # ("amount", "<", 0), + # ("date", ">", fiscal_year), + # ], + # ["amount:sum"], + # [], + # )[0] + # .get("amount", 0.0) + # ) + # + # variation = cash_in + cash_out + # + # return { + # "invoiced": self._format_monetary(invoiced), + # "toinvoice": self._format_monetary(toinvoice), + # "ordered": self._format_monetary(ordered), + # "pipe_win": self._format_monetary(pipe_win), + # "pipe": self._format_monetary(pipe), + # "ongoing": self._format_monetary(ongoing), + # "target": self._format_monetary(target), + # "invoiced_percentage": invoiced_percentage, + # "toinvoice_percentage": toinvoice_percentage, + # "ordered_percentage": ordered_percentage, + # "pipe_win_percentage": pipe_win_percentage, + # "ongoing_percentage": ongoing_percentage, + # "date_maj": date_maj, + # "available_cash": self._format_monetary(available_cash), + # "cash_by_bank": cash_by_bank, + # "to_get": self._format_monetary(to_get), + # "to_pay": self._format_monetary(to_pay), + # "variation": self._format_monetary(variation), + # "cash_in": self._format_monetary(cash_in), + # "cash_out": self._format_monetary(cash_out), + # } + + @api.model + def dashboard_detail_values(self, date_start=None, date_end=None): + customer = self._customer_detail(date_start, date_end) + employee_time = self._employee_time(date_start, date_end) + return { + "customer": customer, + "customer_totals": self._compute_totals(customer), + "employee_time": employee_time, + "employee_time_totals": self._compute_totals(employee_time), + } + + def _customer_detail(self, date_start=None, date_end=None): + clause = "where 1=1 " + if date_start: + clause += f"and line_date >= '{date_start}' " + if date_end: + clause += f"and line_date <= '{date_end}' " + + query = """ + select + customer as "Client", + sum(prod) as "Imput.", + sum(invoiced + purchase + expense)::int as "Balance Prod", + sum(invoiced + purchase + expense)/NULLIF(sum(prod), 0) as "Taux Horaire", + sum(invoiced + invoiced_mco) as "Tot. Fact.", + sum(invoiced)::int as "Fact. Prod", + sum(invoiced_mco)::int as "Fact. Maint", + sum(purchase)::int as "Achats", + sum(expense)::int as "NdF" + from + ( + -- Sélection des heures + select + aal.date as line_date, + p.name as customer, + -- contact != Filament et projet != Maintenance et pas flagué vacances + case when aal.partner_id != 1 or aal.partner_id is null + and aal.holiday_id is null and project_id != 19 + then unit_amount else 0 end as prod, + 0 as invoiced, + 0 as invoiced_mco, + 0 as purchase, + 0 as expense + from + account_analytic_line aal + left join + res_partner p on aal.partner_id = p.id + left join + hr_leave l on aal.holiday_id = l.id + left join + hr_leave_type lt on l.holiday_status_id = lt.id + where + aal.project_id is not null + and aal.date <= CURRENT_DATE + and (lt.active is true or lt.active is null) + and partner_id != 1 + + -- Sélection du facturé hors maintenance + union all + select + aml.date as line_date, + p.name as customer, + 0 as prod, + (aml.credit - aml.debit) as invoiced, + 0 as invoiced_mco, + 0 as purchase, + 0 as expense + from account_move_line aml + left join account_move i on aml.move_id = i.id + left join res_partner p on i.beneficiary_id = p.id + where + i.move_type in ('out_invoice', 'out_refund') + and i.state = 'posted' + and aml.product_id is not null + and aml.product_id not in (33, 34, 50, 51, 61, 62) + + -- Sélection du facturé Maintenance + union all + select + aml.date as line_date, + p.name as customer, + 0 as prod, + 0 as invoiced, + (aml.credit - aml.debit) as invoiced_mco, + 0 as purchase, + 0 as expense + from account_move_line aml + left join account_move i on aml.move_id = i.id + left join res_partner p on i.beneficiary_id = p.id + where + i.move_type in ('out_invoice', 'out_refund') + and i.state = 'posted' + and aml.product_id is not null + and aml.product_id in (33, 34, 50, 51, 61, 62) + + -- Sélection des charges + union all + select + aal.date as line_date, + p.name as customer, + 0 as prod, + 0 as invoiced, + 0 as invoiced_mco, + amount as purchase, + 0 as expense + from account_analytic_line aal + left join account_move_line aml on aal.move_line_id = aml.id + left join account_move i on aml.move_id = i.id + left join account_analytic_account a on aal.account_id = a.id + left join res_partner p on a.partner_id = p.id + where + (a.plan_id is null or a.plan_id = 1) + and i.state = 'posted' + and aml.journal_id = 2 + + -- Sélection des NDF + union all + select + aal.date as line_date, + p.name as customer, + 0 as prod, + 0 as invoiced, + 0 as invoiced_mco, + 0 as purchase, + amount as expense + from account_analytic_line aal + left join account_move_line aml on aal.move_line_id = aml.id + left join account_move i on aml.move_id = i.id + left join account_analytic_account a on aal.account_id = a.id + left join res_partner p on a.partner_id = p.id + where + (a.plan_id is null or a.plan_id = 1) + and i.state = 'posted' + and aml.journal_id = 9 + ) query + %s + group by + customer + order by + sum(invoiced) desc + """ + self.env.cr.execute(query, (AsIs(clause),)) + return self.env.cr.dictfetchall() + + def _employee_time(self, date_start=None, date_end=None): + clause = "" + if date_start: + clause += f" and aal.date >= '{date_start}'" + if date_end: + clause += f" and aal.date <= '{date_end}'" + + query = """ + select + employee as "Employé", + sum(production) as "Prod", + sum(internal) as "Interne", + sum(revenue) as "CA" + + from ( + select + p.name as employee, + 0 as production, + 0 as internal, + sum(aal.amount) as revenue + from + account_analytic_line aal + left join + account_analytic_account aa on aal.account_id = aa.id + left join + account_analytic_plan aap on aa.plan_id = aap.id + left join + res_partner p on aa.partner_id = p.id + where + aap.id = 3 + %s + group by p.name + + union + + select + e.name as employee, + sum(case when aal.partner_id != 1 or aal.partner_id is null + and aal.holiday_id is null then unit_amount else 0 end) + as production, + sum(case when aal.partner_id = 1 and aal.holiday_id is null + then unit_amount else 0 end) + as internal, + 0 as revenue + from + account_analytic_line aal + left join + hr_employee e on aal.employee_id = e.id + left join + hr_leave l on aal.holiday_id = l.id + left join + hr_leave_type lt on l.holiday_status_id = lt.id + where + aal.project_id is not null + %s + group by e.name + ) query + group by employee + order by sum(production) desc + """ + self.env.cr.execute( + query, + ( + AsIs(clause), + AsIs(clause), + ), + ) + return self.env.cr.dictfetchall() + + @staticmethod + def _compute_totals(result): + if not isinstance(result, list) or not result: + return {} + last_result = result[-1] + totals = {} + for column in last_result.keys(): + if isinstance(last_result.get(column), int | float): + totals.update( + { + column: sum( + list( + map( + lambda d: d.get(column) if d.get(column) else 0.0, + result, + ) + ) + ) + } + ) + else: + totals.update({column: False}) + return totals diff --git a/static/src/components/dashboard_detail.esm.js b/static/src/components/dashboard_detail.esm.js new file mode 100644 index 0000000000000000000000000000000000000000..29a635479e23f1f5548a8c9cd3573d22157aae25 --- /dev/null +++ b/static/src/components/dashboard_detail.esm.js @@ -0,0 +1,107 @@ +/** @odoo-module */ + +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; +const {Component, useState, onWillStart} = owl; + +export class LFDetailsDashboard extends Component { + setup() { + this.state = useState({ + date_start: null, + date_end: null, + customer: [], + customer_totals: {}, + employee_time: [], + employee_time_totals: {}, + }); + this.orm = useService("orm"); + + onWillStart(async () => { + await this.getData(); + }); + } + + async getData() { + const result = await this.orm.call( + "lefilament.dashboard", + "dashboard_detail_values", + [], + { + date_start: this.state.date_start || null, + date_end: this.state.date_end || null, + } + ); + if (result) { + this.state.customer = result.customer || []; + this.state.customer_totals = result.customer_totals || {}; + this.state.employee_time = result.employee_time || []; + this.state.employee_time_totals = result.employee_time_totals || {}; + } + } + + async updatePeriod(period) { + const date = new Date(); + switch (period) { + case "this_month": + this.state.date_start = new Date( + date.getFullYear(), + date.getMonth(), + 1, + 12 + ) + .toISOString() + .split("T")[0]; + this.state.date_end = new Date( + date.getFullYear(), + date.getMonth() + 1, + 0, + 12 + ) + .toISOString() + .split("T")[0]; + break; + case "last_month": + this.state.date_start = new Date( + date.getFullYear(), + date.getMonth() - 1, + 1, + 12 + ) + .toISOString() + .split("T")[0]; + this.state.date_end = new Date( + date.getFullYear(), + date.getMonth(), + 0, + 12 + ) + .toISOString() + .split("T")[0]; + break; + case "this_year": + this.state.date_start = new Date(date.getFullYear(), 0, 1, 12) + .toISOString() + .split("T")[0]; + this.state.date_end = new Date(date.getFullYear() + 1, 0, 0, 12) + .toISOString() + .split("T")[0]; + break; + case "last_year": + this.state.date_start = new Date(date.getFullYear() - 1, 0, 1, 12) + .toISOString() + .split("T")[0]; + this.state.date_end = new Date(date.getFullYear(), 0, 0, 12) + .toISOString() + .split("T")[0]; + break; + default: + this.state.date_start = null; + this.state.date_end = null; + } + await this.getData(); + } +} + +LFDetailsDashboard.template = "lefilament_tdb.LFDetailsDashboard"; + +registry.category("actions").add("lefilament_tdb.dashboard_detail", LFDetailsDashboard); diff --git a/static/src/components/dashboard_detail.xml b/static/src/components/dashboard_detail.xml new file mode 100644 index 0000000000000000000000000000000000000000..3ff716588f15a95ed3203a95209281f97b325b52 --- /dev/null +++ b/static/src/components/dashboard_detail.xml @@ -0,0 +1,193 @@ +<?xml version="1.0" encoding="utf-8" ?> +<templates xml:space="preserve"> + <t t-name="lefilament_tdb.LFDetailsDashboard" owl="1"> + <div id="dashboard_detail_values"> + <t t-call="lefilament_tdb.dashboard_detail_values" /> + </div> + </t> + <t t-name="lefilament_tdb.dashboard_detail_values" owl="1"> + <div class="vh-100 overflow-auto bg-muted"> + <div class="row m-3"> + <div class="col-lg-12"> + <div class="row"> + <div class="col m-0 p-0"> + <div class="shadow-sm border m-2 p-4 bg-white"> + <div + class="d-flex align-items-center justify-content-between" + > + <h1 class="text-primary fw-bold">Rapport Annuel</h1> + </div> + </div> + </div> + </div> + <div class="row"> + <div class="col m-0 p-0"> + <div class="shadow-sm border m-2 p-4 bg-white"> + <div + class="d-flex align-items-center justify-content-between" + > + <div + class="btn-group" + role="group" + aria-label="Dates buttons" + > + <button + type="object" + class="btn btn-outline-primary button-period" + name="this_month" + t-on-click="() => this.updatePeriod('this_month')" + > + Ce mois-ci + </button> + <button + type="object" + class="btn btn-outline-primary button-period" + name="last_month" + t-on-click="() => this.updatePeriod('last_month')" + > + Le mois dernier + </button> + <button + type="object" + class="btn btn-outline-primary button-period" + name="this_year" + t-on-click="() => this.updatePeriod('this_year')" + > + Cette année + </button> + <button + type="object" + class="btn btn-outline-primary button-period" + name="last_year" + t-on-click="() => this.updatePeriod('last_year')" + > + L'année dernière + </button> + <button + type="object" + class="btn btn-outline-primary button-period" + name="all" + t-on-click="() => this.updatePeriod('all')" + > + Tout + </button> + </div> + <div class="d-flex align-items-center"> + <div + class="d-flex align-items-center me-2 px-2" + > + <label + for="date_start" + class="form-label date-label px-2" + > + <h5>Du:</h5> + </label> + <input + type="date" + id="date_start" + class="form-control date-input" + t-model="state.date_start" + /> + </div> + <div + class="d-flex align-items-center me-2 px-2" + > + <label + for="date_end" + class="form-label date-label px-2" + > + <h5>Au:</h5> + </label> + <input + type="date" + id="date_end" + class="form-control date-input" + t-model="state.date_end" + /> + </div> + </div> + <button + class="btn btn-primary px-4" + t-on-click="getData" + >Mettre à jour</button> + </div> + </div> + </div> + </div> + <div class="row dashboard_detail"> + <div class="col-12" t-if="state.date_start"> + <div class="mt-2 mb-4 display-6 text-center"> + Période : + <t t-esc="state.date_start" /> - + <t t-esc="state.date_end" /> + </div> + </div> + <div class="col-7" t-if="state.customer.length > 0"> + <h3 class="text-uppercase py-2 mb-0">Rentabilité client</h3> + <hr class="mt-0" /> + <t t-call="lefilament_tdb.dashboard_detail_table"> + <t t-set="data" t-value="state.customer" /> + <t t-set="totals" t-value="state.customer_totals" /> + </t> + </div> + <div class="col-5" t-if="state.employee_time.length > 0"> + <h3 class="text-uppercase py-2 mb-0">Imputations</h3> + <hr class="mt-0" /> + <t t-call="lefilament_tdb.dashboard_detail_table"> + <t t-set="data" t-value="state.employee_time" /> + <t t-log="data" /> + <t + t-set="totals" + t-value="state.employee_time_totals" + /> + </t> + </div> + </div> + </div> + </div> + </div> + </t> + + <!-- Table Template --> + <t t-name="lefilament_tdb.dashboard_detail_table"> + <div + t-if="data.length > 0" + class="px-4 mb-5" + style="max-height: 600px; overflow: scroll; border: 1px solid #eee;" + > + <table class="table table-hover table-striped"> + <thead> + <tr class="bg-100"> + <th>#</th> + <th t-foreach="data[0]" t-as="header" t-key="header_index"> + <t t-esc="header" /> + </th> + </tr> + </thead> + <tbody> + <tr t-foreach="data" t-as="line" t-key="line_index"> + <td t-esc="line_index + 1" /> + <t t-foreach="line" t-as="v" t-key="v_index"> + <td class="text-end"> + <t t-esc="v_value" /> + </td> + </t> + </tr> + </tbody> + <tfoot t-if="totals"> + <tr class="bg-100"> + <th>Total</th> + <th + t-foreach="totals" + t-as="footer" + class="text-end" + t-key="footer_index" + > + <t t-if="footer_value != false" t-esc="footer_value" /> + </th> + </tr> + </tfoot> + </table> + </div> + </t> +</templates> diff --git a/static/src/components/dashboard_overview.esm.js b/static/src/components/dashboard_overview.esm.js new file mode 100644 index 0000000000000000000000000000000000000000..c0f0de3a1c6e8e8a241df3463442a17c99f6f29a --- /dev/null +++ b/static/src/components/dashboard_overview.esm.js @@ -0,0 +1,347 @@ +/** @odoo-module */ + +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; +const {Component, useState, onWillStart} = owl; + +export class LFDashboard extends Component { + setup() { + this.state = useState({}); + this.orm = useService("orm"); + this.user = useService("user"); + this.actionService = useService("action"); + + onWillStart(async () => { + await this.getData(); + }); + } + + async getData() { + const fiscal_year = new Date().getFullYear() + "-01-01"; + const fiscal_year_next = new Date().getFullYear() + "-12-31"; + + const ca_target = await this.orm.searchRead( + "res.company", + [["id", "=", this.user.context.allowed_company_ids[0]]], + ["ca_target"] + ); + const target = ca_target[0].ca_target; + + const domain_invoice = [ + ["invoice_date", ">=", fiscal_year], + ["invoice_date", "<=", fiscal_year_next], + ["move_type", "in", ["out_invoice", "out_refund"]], + ["state", "=", "posted"], + ]; + const invoiced_rg = await this.orm.readGroup( + "account.move", + domain_invoice, + ["amount_untaxed_signed:sum"], + [] + ); + const invoiced_percentage = + (invoiced_rg[0].amount_untaxed_signed / target) * 100; + const toinvoice_percentage = + ((target - invoiced_rg[0].amount_untaxed_signed) / target) * 100; + + const domain_order = [["invoice_status", "=", "to invoice"]]; + const ordered_rg = await this.orm.readGroup( + "sale.order", + domain_order, + ["untaxed_amount_to_invoice:sum"], + [] + ); + const ordered_percentage = + (ordered_rg[0].untaxed_amount_to_invoice / target) * 100; + + const crm_domain = [ + "|", + ["date_deadline", "<=", fiscal_year_next], + ["date_deadline", "=", false], + ]; + const pipe_rg = await this.orm.readGroup( + "crm.lead", + crm_domain, + ["prorated_revenue:sum"], + [] + ); + const crm_win_domain = [ + ["probability", "=", 100], + "|", + ["date_deadline", "<=", fiscal_year_next], + ["date_deadline", "=", false], + ]; + const pipe_win_rg = await this.orm.readGroup( + "crm.lead", + crm_win_domain, + ["prorated_revenue:sum"], + [] + ); + const pipe_win_percentage = (pipe_win_rg[0].prorated_revenue / target) * 100; + + const ongoing = + invoiced_rg[0].amount_untaxed_signed + + ordered_rg[0].price_subtotal + + pipe_win_rg[0].prorated_revenue; + const ongoing_percentage = (ongoing / target) * 100; + + const last_date = await this.orm.searchRead( + "account.bank.statement.line", + [], + ["date"], + {limit: 1, order: "date desc"} + ); + const date_maj = new Date(last_date[0].date).toLocaleDateString("fr-FR"); + + const available_cash_rg = await this.orm.readGroup( + "account.bank.statement.line", + [], + ["amount:sum"], + [] + ); + const cash_by_bank = await this.orm.readGroup( + "account.bank.statement.line", + [], + ["journal_id", "amount"], + ["journal_id"] + ); + + const domain_invoice_to_get = [ + ["move_type", "in", ["out_invoice", "out_refund"]], + ["state", "=", "posted"], + ["payment_state", "in", ["not_paid", "partial", "in_payment"]], + ]; + const to_get_rg = await this.orm.readGroup( + "account.move", + domain_invoice_to_get, + ["amount_untaxed_signed:sum"], + [] + ); + const domain_invoice_to_pay = [ + ["move_type", "in", ["in_invoice", "in_refund"]], + ["state", "=", "posted"], + ["payment_state", "in", ["not_paid", "partial", "in_payment"]], + ]; + const to_pay_rg = await this.orm.readGroup( + "account.move", + domain_invoice_to_pay, + ["amount_untaxed_signed:sum"], + [] + ); + + const domain_bank_line = [["date", ">", fiscal_year]]; + const cash_in_rg = await this.orm.readGroup( + "account.bank.statement.line", + [ + ["amount", ">", 0], + ["date", ">", fiscal_year], + ], + ["amount:sum"], + [] + ); + const cash_out_rg = await this.orm.readGroup( + "account.bank.statement.line", + [ + ["amount", "<", 0], + ["date", ">", fiscal_year], + ], + ["amount:sum"], + [] + ); + const variation = cash_in_rg[0].amount + cash_out_rg[0].amount; + + this.state = { + domain_invoice: domain_invoice, + invoiced: invoiced_rg[0].amount_untaxed_signed.toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }), + toinvoice: (target - invoiced_rg[0].amount_untaxed_signed).toLocaleString( + "fr-FR", + {style: "currency", currency: "EUR", maximumFractionDigits: 0} + ), + domain_order: domain_order, + ordered: ordered_rg[0].untaxed_amount_to_invoice.toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }), + pipe_win: pipe_win_rg[0].prorated_revenue.toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }), + pipe: pipe_rg[0].prorated_revenue.toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }), + crm_domain: crm_domain, + ongoing: ongoing.toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }), + target: target.toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }), + invoiced_percentage: invoiced_percentage.toLocaleString("fr-FR", { + maximumFractionDigits: 0, + }), + toinvoice_percentage: toinvoice_percentage.toLocaleString("fr-FR", { + maximumFractionDigits: 0, + }), + ordered_percentage: ordered_percentage.toLocaleString("fr-FR", { + maximumFractionDigits: 0, + }), + pipe_win_percentage: pipe_win_percentage.toLocaleString("fr-FR", { + maximumFractionDigits: 0, + }), + ongoing_percentage: ongoing_percentage.toLocaleString("fr-FR", { + maximumFractionDigits: 0, + }), + date_maj: date_maj, + available_cash: available_cash_rg[0].amount.toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }), + cash_by_bank: cash_by_bank, + domain_invoice_to_get: domain_invoice_to_get, + to_get: to_get_rg[0].amount_untaxed_signed.toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }), + domain_invoice_to_pay: domain_invoice_to_pay, + to_pay: to_pay_rg[0].amount_untaxed_signed.toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }), + domain_bank_line: domain_bank_line, + variation: variation.toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }), + cash_in: cash_in_rg[0].amount.toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }), + cash_out: cash_out_rg[0].amount.toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }), + }; + } + + async updateView() { + this.actionService.doAction({ + type: "ir.actions.client", + tag: "soft_reload", + }); + } + + async viewInvoices() { + this.actionService.doAction({ + type: "ir.actions.act_window", + name: "Facturé cette année", + res_model: "account.move", + domain: this.state.domain_invoice, + views: [ + [0, "list"], + [0, "pivot"], + [0, "graph"], + [0, "form"], + ], + }); + } + + async viewOrders() { + this.actionService.doAction({ + type: "ir.actions.act_window", + name: "Commandes en cours", + res_model: "sale.order", + domain: this.state.domain_order, + views: [ + [0, "list"], + [0, "pivot"], + [0, "graph"], + [0, "form"], + ], + }); + } + + async viewPipe() { + this.actionService.doAction({ + type: "ir.actions.act_window", + name: "Pipe", + res_model: "crm.lead", + domain: this.state.crm_domain, + views: [ + [0, "kanban"], + [0, "list"], + [0, "pivot"], + [0, "graph"], + [0, "form"], + ], + }); + } + + async viewBankStatements() { + this.actionService.doAction("account.action_bank_statement_tree"); + } + + async viewToGetInvoices() { + this.actionService.doAction({ + type: "ir.actions.act_window", + name: "Factures à encaisser", + res_model: "account.move", + domain: this.state.domain_invoice_to_get, + views: [ + [0, "list"], + [0, "pivot"], + [0, "graph"], + [0, "form"], + ], + }); + } + + async viewToPayInvoices() { + this.actionService.doAction({ + type: "ir.actions.act_window", + name: "Factures à payer", + res_model: "account.move", + domain: this.state.domain_invoice_to_pay, + views: [ + [0, "list"], + [0, "pivot"], + [0, "graph"], + [0, "form"], + ], + }); + } + + async viewBankLines() { + this.actionService.doAction({ + type: "ir.actions.act_window", + name: "Trésorerie cette année", + res_model: "account.bank.statement.line", + domain: this.state.domain_bank_line, + views: [ + [0, "list"], + [0, "form"], + ], + }); + } +} + +LFDashboard.template = "lefilament_tdb.LFDashboard"; + +registry.category("actions").add("lefilament_tdb.dashboard_overview", LFDashboard); diff --git a/static/src/components/dashboard_overview.xml b/static/src/components/dashboard_overview.xml new file mode 100644 index 0000000000000000000000000000000000000000..3b64b718a33ff44b4f90bdff773199b10130d091 --- /dev/null +++ b/static/src/components/dashboard_overview.xml @@ -0,0 +1,394 @@ +<?xml version="1.0" encoding="utf-8" ?> +<templates xml:space="preserve"> + <t t-name="lefilament_tdb.LFDashboard" owl="1"> + <div class="vh-100 overflow-auto bg-muted"> + <div class="row m-3"> + <div class="col-lg-12"> + <div class="row"> + <div class="col m-0 p-0"> + <div class="shadow-sm border m-2 p-4 bg-white"> + <div + class="d-flex align-items-center justify-content-between" + > + <h1 class="text-primary fw-bold">Rapport Annuel</h1> + <button + type="object" + class="btn" + id="update_view" + t-on-click="updateView" + > + <i class="fa fa-refresh" /> + </button> + </div> + </div> + </div> + </div> + <div class="row lefilament_dashboard"> + <!-- Column 1 --> + <div class="col-12 col-md-6 col-lg-4"> + <h3 + class="text-uppercase fw-bolder o_horizontal_separator mb-4" + >En Cours</h3> + <!-- Facturé --> + <a + class="card mb32" + t-on-click="viewInvoices" + type="action" + > + <div class="card-body"> + <h5 class="card-title">Facturé</h5> + <p class="display-6"> + <t t-esc="state.invoiced" /> + </p> + </div> + </a> + <!-- Commandes --> + <a class="card mb32" t-on-click="viewOrders" type="action"> + <div class="card-body"> + <h5 class="card-title">Commandes en cours</h5> + <p class="display-6" t-esc="state.ordered" /> + </div> + </a> + <!-- Pipe --> + <a class="card mb32" t-on-click="viewPipe" type="action"> + <div class="card-body"> + <h5 class="card-title">Pipe</h5> + <p class="display-6" t-esc="state.pipe" /> + </div> + </a> + </div> + + <!-- Column 2 --> + <div class="col-12 col-md-6 col-lg-4"> + <h3 + class="text-uppercase fw-bolder o_horizontal_separator mb-4" + >Objectif</h3> + <div class="card"> + <div class="card-body"> + <p class="display-5 mb16" t-esc="state.target" /> + <div class="progress" style="height: 60px;"> + <div + class="progress-bar progress-bar-striped bg-success" + role="progressbar" + t-att-aria-valuenow="state.invoiced_percentage" + aria-valuemin="0" + aria-valuemax="100" + t-attf-style="width: {{state.invoiced_percentage}}%; font-size: 14px; font-weight: 600;" + > + <t t-esc="state.invoiced_percentage" /> % + </div> + </div> + <table class="table-legend mt16"> + <tr> + <td + style="background-color: #8ED8A2; width: 20px;" + class="progress-bar-striped" + /> + <td>Facturé</td> + <td class="nb" t-esc="state.invoiced" /> + <td class="nb"><t + t-esc="state.invoiced_percentage" + /> %</td> + </tr> + <tr> + <td + style="background-color: #F6DCA2; width: 20px;" + class="progress-bar-striped" + /> + <td>Commandes</td> + <td class="nb" t-esc="state.ordered" /> + <td class="nb"><t + t-esc="state.ordered_percentage" + /> %</td> + </tr> + <tr> + <td + style="background-color: #F6CCA2; width: 20px;" + class="progress-bar-striped" + /> + <td>Pipe Gagné</td> + <td class="nb" t-esc="state.pipe_win" /> + <td class="nb"><t + t-esc="state.pipe_win_percentage" + /> %</td> + </tr> + <tr class="table-legend-total"> + <td style="width: 20px;" /> + <td>Total</td> + <td class="nb" t-esc="state.ongoing" /> + <td class="nb"><t + t-esc="state.ongoing_percentage" + /> %</td> + </tr> + <tr> + <td + style="background-color: #eee; width: 20px;" + class="progress-bar-striped" + /> + <td>À facturer</td> + <td class="nb" t-esc="state.toinvoice" /> + <td class="nb"> + <t + t-esc="state.toinvoice_percentage" + /> % + </td> + </tr> + </table> + </div> + </div> + </div> + + <!-- Column 3 --> + <div class="col-12 col-md-6 col-lg-4"> + <h3 + class="text-uppercase fw-bolder o_horizontal_separator mb-4" + >Trésorerie</h3> + <!-- Banques --> + <div class="card mb32"> + <div class="card-body"> + <ul + class="nav nav-tabs" + id="cash-tab" + role="tablist" + > + <li class="nav-item" role="presentation"> + <button + class="nav-link active" + id="cash-tab" + data-bs-toggle="tab" + data-bs-target="#cash" + type="button" + role="tab" + aria-controls="cash" + aria-selected="true" + > + Trésorerie + </button> + </li> + <li class="nav-item" role="presentation"> + <button + class="nav-link" + id="bank-tab" + data-bs-toggle="tab" + data-bs-target="#bank" + type="button" + role="tab" + aria-controls="bank" + aria-selected="false" + >Banques + </button> + </li> + </ul> + <div class="tab-content mt16" id="cashContent"> + <div + class="tab-pane fade show active" + id="cash" + role="tabpanel" + aria-labelledby="cash-tab" + > + <a + type="action" + t-on-click="viewBankStatements" + > + <p class="card-maj"> + Denière mise à jour le <t + t-esc="state.date_maj" + /></p> + <p + class="display-6" + t-esc="state.available_cash" + /> + </a> + </div> + <div + class="tab-pane fade" + id="bank" + role="tabpanel" + aria-labelledby="bank-tab" + > + <table + class="table table-striped table-sm table-bordered table-hover" + > + <t + t-foreach="state.cash_by_bank" + t-as="bank" + t-key="bank_index" + > + <tr> + <td t-esc="bank['journal_id'][1]" /> + <td class="text-right"> + <t t-esc="bank['amount']" /> + </td> + </tr> + </t> + </table> + </div> + </div> + </div> + </div> + + <!-- To paid --> + <div class="card mb32"> + <div class="card-body"> + <ul + class="nav nav-tabs" + id="cash-tab" + role="tablist" + > + <li + class="nav-item" + role="presentation" + > + <button + class="nav-link active" + id="cust_to_pay-tab" + data-bs-toggle="tab" + data-bs-target="#cust_to_pay" + type="button" + role="tab" + aria-controls="cust_to_pay" + aria-selected="true" + > + Facturé non encaissé + </button> + </li> + <li + class="nav-item" + role="presentation" + > + <button + class="nav-link" + id="provider_to_pay-tab" + data-bs-toggle="tab" + data-bs-target="#provider_to_pay" + type="button" + role="tab" + aria-controls="provider_to_pay" + aria-selected="false" + >Fournisseurs + </button> + </li> + </ul> + <div + class="tab-content mt16" + id="toPayContent" + > + <div + class="tab-pane fade show active" + id="cust_to_pay" + role="tabpanel" + aria-labelledby="cust_to_pay-tab" + > + <a + t-on-click="viewToGetInvoices" + type="action" + > + <p + class="display-6" + t-esc="state.to_get" + /> + </a> + </div> + <div + class="tab-pane fade" + id="provider_to_pay" + role="tabpanel" + aria-labelledby="provider_to_pay-tab" + > + <a + t-on-click="viewToPayInvoices" + type="action" + > + <p + class="display-6" + t-esc="state.to_pay" + /> + </a> + </div> + </div> + </div> + </div> + + <!-- Variation --> + <div class="card mb32"> + <div class="card-body"> + <ul + class="nav nav-tabs" + id="variation-tab" + role="tablist" + > + <li class="nav-item" role="presentation"> + <button + class="nav-link active" + id="variation-tab" + data-bs-toggle="tab" + data-bs-target="#variation" + type="button" + role="tab" + aria-controls="variation" + aria-selected="true" + > + Variation + </button> + </li> + <li class="nav-item" role="presentation"> + <button + class="nav-link" + id="inout-tab" + data-bs-toggle="tab" + data-bs-target="#inout" + type="button" + role="tab" + aria-controls="inout" + aria-selected="false" + >Entrées/Sorties + </button> + </li> + </ul> + <div class="tab-content mt16" id="variationContent"> + <div + class="tab-pane fade show active" + id="variation" + role="tabpanel" + aria-labelledby="variation-tab" + > + <a t-on-click="viewBankLines" type="action"> + <p + class="display-6" + t-esc="state.variation" + /> + </a> + </div> + <div + class="tab-pane fade" + id="inout" + role="tabpanel" + aria-labelledby="inout-tab" + > + <div class="row"> + <div class="col-6"> + <p><strong>Encaissé</strong></p> + <p + class="display-6" + t-esc="state.cash_in" + /> + </div> + <div class="col-6"> + <p><strong>Décaissé</strong></p> + <p + class="display-6" + t-esc="state.cash_out" + /> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </t> +</templates> diff --git a/static/src/components/lefilament_tdb.css b/static/src/components/lefilament_tdb.css new file mode 100644 index 0000000000000000000000000000000000000000..07acd844cc7d993bab748c415fb0098557c8ecb5 --- /dev/null +++ b/static/src/components/lefilament_tdb.css @@ -0,0 +1,43 @@ +.lefilament_dashboard { + padding-top: 40px; + padding-bottom: 40px; + background-color: #fefefe; +} +.lefilament_dashboard .display-6, +.lefilament_dashboard .card-title { + color: #495057; +} +.lefilament_dashboard a.dashboard_view { + cursor: pointer; +} + +p.card-maj { + font-size: 10px; + font-style: italic; + font-weight: 300; +} + +/* Legende ProgressBar */ +.table-legend { + width: 100%; + max-width: 100%; + margin: 10px 0; + border-collapse: separate; + border-spacing: 8px; + font-size: 11px; +} +.table-legend .nb { + text-align: right; +} +.table-legend-total td { + border-top: 1px solid #eee; + border-bottom: 1px solid #eee; + font-weight: 700; + padding: 2px 0; +} + +.dashboard_detail { + padding-top: 10px; + padding-bottom: 40px; + background-color: #fefefe; +} diff --git a/views/lefilament_dashboard.xml b/views/lefilament_dashboard.xml index af9db75f1da180c036f4604d599aa79b7921c6b2..eca53642e323ce104f42e6f09f07b00f19b39a81 100644 --- a/views/lefilament_dashboard.xml +++ b/views/lefilament_dashboard.xml @@ -140,4 +140,12 @@ <field name="res_model">lefilament.dashboard</field> <field name="view_mode">tree,graph,form,pivot</field> </record> + <record id="le_filament_dashboard_overview_action" model="ir.actions.client"> + <field name="name">Rapport Annuel</field> + <field name="tag">lefilament_tdb.dashboard_overview</field> + </record> + <record id="le_filament_dashboard_detail_action" model="ir.actions.client"> + <field name="name">Détail</field> + <field name="tag">lefilament_tdb.dashboard_detail</field> + </record> </odoo> diff --git a/views/menus.xml b/views/menus.xml index 91f611f6d41a3945bead3372ed2b5178e74c01ba..424ab945debfe244e2a750ec2f0324bb638ff1e8 100644 --- a/views/menus.xml +++ b/views/menus.xml @@ -9,6 +9,27 @@ groups="group_dashboard" web_icon="lefilament_tdb,static/description/icon_menu.png" /> + <menuitem + id="lefilament_dashboard_report" + parent="lefilament_dashboard_menu" + name="Rapports" + sequence="1" + /> + + <menuitem + id="lefilament_dashboard_report_year" + parent="lefilament_dashboard_report" + name="Annuel" + sequence="1" + action="lefilament_tdb.le_filament_dashboard_overview_action" + /> + <menuitem + id="lefilament_dashboard_report_detail" + parent="lefilament_dashboard_report" + name="Détail" + sequence="1" + action="lefilament_tdb.le_filament_dashboard_detail_action" + /> <menuitem id="lefilament_dashboard_conf"