diff --git a/.eslintrc.yml b/.eslintrc.yml
index 9429bc688aab4993eae460767368f65089afa323..fed88d70d23ecb3297ea28854b320c4d62ee3c26 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -22,6 +22,7 @@ globals:
   odoo: readonly
   openerp: readonly
   owl: readonly
+  luxon: readonly
 
 # Styling is handled by Prettier, so we only need to enable AST rules;
 # see https://github.com/OCA/maintainer-quality-tools/pull/618#issuecomment-558576890
diff --git a/.flake8 b/.flake8
deleted file mode 100644
index e397e8ed4e3e7f7fe7785dd391bb80aa6d85575e..0000000000000000000000000000000000000000
--- a/.flake8
+++ /dev/null
@@ -1,12 +0,0 @@
-[flake8]
-max-line-length = 88
-max-complexity = 16
-# B = bugbear
-# B9 = bugbear opinionated (incl line length)
-select = C,E,F,W,B,B9
-# E203: whitespace before ':' (black behaviour)
-# E501: flake8 line length (covered by bugbear B950)
-# W503: line break before binary operator (black behaviour)
-ignore = E203,E501,W503
-per-file-ignores=
-    __init__.py:F401
diff --git a/.gitignore b/.gitignore
index 818770fb1bdc0a144e924c9a5940f0b035df8a0d..d99361a24aa02c2d19d8165c3a057b84bc575166 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ __pycache__/
 *.py[cod]
 /.venv
 /.pytest_cache
+/.ruff_cache
 
 # C extensions
 *.so
@@ -15,7 +16,6 @@ build/
 develop-eggs/
 dist/
 eggs/
-lib/
 lib64/
 parts/
 sdist/
@@ -24,6 +24,7 @@ var/
 .installed.cfg
 *.egg
 *.eggs
+.copier-answers.yml
 
 # Installer logs
 pip-log.txt
diff --git a/.isort.cfg b/.isort.cfg
deleted file mode 100644
index 0ec187efd1bf802844749f508cda0c8f138970f9..0000000000000000000000000000000000000000
--- a/.isort.cfg
+++ /dev/null
@@ -1,13 +0,0 @@
-[settings]
-; see https://github.com/psf/black
-multi_line_output=3
-include_trailing_comma=True
-force_grid_wrap=0
-combine_as_imports=True
-use_parentheses=True
-line_length=88
-known_odoo=odoo
-known_odoo_addons=odoo.addons
-sections=FUTURE,STDLIB,THIRDPARTY,ODOO,ODOO_ADDONS,FIRSTPARTY,LOCALFOLDER
-default_section=THIRDPARTY
-ensure_newline_before_comments = True
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 732d0c4a644eb444d6b4385643ff32fab19fab52..740d10c003557349d6b59ab07ee2da7c6c9d270d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -6,10 +6,16 @@ exclude: |
   ^setup/|/static/description/index\.html$|
   # We don't want to mess with tool-generated files
   .svg$|/tests/([^/]+/)?cassettes/|^.copier-answers.yml$|^.github/|
+  # Maybe reactivate this when all README files include prettier ignore tags?
+  ^README\.md$|
   # Library files can have extraneous formatting (even minimized)
   /static/(src/)?lib/|
   # Repos using Sphinx to generate docs don't need prettying
   ^docs/_templates/.*\.html$|
+  # Don't bother non-technical authors with formatting issues in docs
+  readme/.*\.(rst|md)$|
+  # Ignore build and dist directories in addons
+  /build/|/dist/|
   # You don't usually want a bot to modify your legal texts
   (LICENSE.*|COPYING.*)
 default_language_version:
@@ -25,30 +31,34 @@ repos:
         entry: found forbidden files; remove them
         language: fail
         files: "\\.rej$"
+      - id: en-po-files
+        name: en.po files cannot exist
+        entry: found a en.po file
+        language: fail
+        files: '[a-zA-Z0-9_]*/i18n/en\.po$'
   - repo: https://github.com/oca/maintainer-tools
-    rev: 7d8a9f9ad73db0976fb03cbee43d953bc29b89e9
+    rev: 9a170331575a265c092ee6b24b845ec508e8ef75
     hooks:
       # update the NOT INSTALLABLE ADDONS section above
       - id: oca-update-pre-commit-excluded-addons
       - id: oca-fix-manifest-website
         args: ["https://le-filament.com"]
-  - repo: https://github.com/myint/autoflake
-    rev: v1.4
-    hooks:
-      - id: autoflake
+      - id: oca-gen-addon-readme
         args:
-          - --expand-star-imports
-          - --ignore-init-module-imports
-          - --in-place
-          - --remove-all-unused-imports
-          - --remove-duplicate-keys
-          - --remove-unused-variables
-  - repo: https://github.com/psf/black
-    rev: 22.3.0
+          - --addons-dir=.
+          - --branch=14.0
+          - --org-name=lefilament
+          - --repo-name=template_module
+          - --if-source-changed
+          - --keep-source-digest
+          - --convert-fragments-to-markdown
+  - repo: https://github.com/OCA/odoo-pre-commit-hooks
+    rev: v0.0.25
     hooks:
-      - id: black
+      - id: oca-checks-odoo-module
+      - id: oca-checks-po
   - repo: https://github.com/pre-commit/mirrors-prettier
-    rev: v2.6.2
+    rev: v2.1.2
     hooks:
       - id: prettier
         name: prettier (with plugin-xml)
@@ -59,7 +69,7 @@ repos:
           - --plugin=@prettier/plugin-xml
         files: \.(css|htm|html|js|json|jsx|less|md|scss|toml|ts|xml|yaml|yml)$
   - repo: https://github.com/pre-commit/mirrors-eslint
-    rev: v8.15.0
+    rev: v7.8.1
     hooks:
       - id: eslint
         verbose: true
@@ -67,7 +77,7 @@ repos:
           - --color
           - --fix
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.2.0
+    rev: v3.2.0
     hooks:
       - id: trailing-whitespace
         # exclude autogenerated files
@@ -89,37 +99,25 @@ repos:
       - id: mixed-line-ending
         args: ["--fix=lf"]
   - repo: https://github.com/asottile/pyupgrade
-    rev: v2.32.1
+    rev: v2.7.2
     hooks:
       - id: pyupgrade
         args: ["--keep-percent-format"]
-  - repo: https://github.com/PyCQA/isort
-    rev: 5.10.1
-    hooks:
-      - id: isort
-        name: isort except __init__.py
-        args:
-          - --settings=.
-        exclude: /__init__\.py$
-  - repo: https://gitlab.com/PyCQA/flake8
-    rev: 3.9.2
+  - repo: https://github.com/astral-sh/ruff-pre-commit
+    rev: v0.1.3
     hooks:
-      - id: flake8
-        name: flake8
-        additional_dependencies: ["flake8-bugbear==20.1.4"]
-  - repo: https://github.com/PyCQA/pylint
-    rev: v2.11.1
+      - id: ruff
+        args: [--fix, --exit-non-zero-on-fix]
+      - id: ruff-format
+  - repo: https://github.com/OCA/pylint-odoo
+    rev: 7.0.2
     hooks:
-      - id: pylint
+      - id: pylint_odoo
         name: pylint with optional checks
         args:
           - --rcfile=.pylintrc
           - --exit-zero
         verbose: true
-        additional_dependencies: &pylint_deps
-          - pylint-odoo==5.0.5
-      - id: pylint
-        name: pylint with mandatory checks
+      - id: pylint_odoo
         args:
           - --rcfile=.pylintrc-mandatory
-        additional_dependencies: *pylint_deps
diff --git a/.pylintrc b/.pylintrc
index dc6270e15be0c08da00e768a570f27c785d8630e..941ea6ba8dc268fa854836ba0651f657a7f98a7b 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -1,3 +1,5 @@
+
+
 [MASTER]
 load-plugins=pylint_odoo
 score=n
@@ -73,6 +75,7 @@ enable=anomalous-backslash-in-string,
     invalid-commit,
     missing-manifest-dependency,
     missing-newline-extrafiles,
+    missing-readme,
     no-utf8-coding-comment,
     odoo-addons-relative-import,
     old-api7-method-defined,
diff --git a/.pylintrc-mandatory b/.pylintrc-mandatory
index 43ea23947166ff8080219007cfae43ec54a28f8e..4336f52d81465175d3cea21e54fe6b6089078d36 100644
--- a/.pylintrc-mandatory
+++ b/.pylintrc-mandatory
@@ -1,3 +1,4 @@
+
 [MASTER]
 load-plugins=pylint_odoo
 score=n
diff --git a/.ruff.toml b/.ruff.toml
new file mode 100644
index 0000000000000000000000000000000000000000..d75c6d979221f4e8efe98381989a52ca4703e863
--- /dev/null
+++ b/.ruff.toml
@@ -0,0 +1,28 @@
+
+fix = true
+
+[lint]
+extend-select = [
+    "B",
+    "C90",
+    "E501",  # line too long (default 88)
+    "I",  # isort
+]
+exclude = ["setup/*"]
+
+[format]
+exclude = ["setup/*"]
+
+[per-file-ignores]
+"__init__.py" = ["F401", "I001"]  # ignore unused and unsorted imports in __init__.py
+"__manifest__.py" = ["B018"]  # useless expression
+
+[isort]
+section-order = ["future", "standard-library", "third-party", "odoo", "odoo-addons", "first-party", "local-folder"]
+
+[isort.sections]
+"odoo" = ["odoo"]
+"odoo-addons" = ["odoo.addons"]
+
+[mccabe]
+max-complexity = 16
diff --git a/__manifest__.py b/__manifest__.py
index ac295f4b8f4787aa240bea07be3cf640cf30df77..2fdbe9ff8d48dbb85f0f791c71ce4d8c6bdeced0 100644
--- a/__manifest__.py
+++ b/__manifest__.py
@@ -13,6 +13,7 @@
         "project_timeline",
         "sale_timesheet",
         "ecozimut_mixin",
+        "lefilament_link_sale_project",
     ],
     "data": [
         "security/ir.model.access.csv",
diff --git a/data/ir_cron_data.xml b/data/ir_cron_data.xml
index 9e9f744d1dad1a2fa28653eb99aec51ed4d84503..b47b627a794c26a9f075dd1e5f1c2076056ea41d 100644
--- a/data/ir_cron_data.xml
+++ b/data/ir_cron_data.xml
@@ -16,7 +16,7 @@
             <field name="code">
 env["project.task.planned.hour"].search([]).unlink()
 env.cr.execute("ALTER SEQUENCE project_task_planned_hour_id_seq RESTART WITH 1")
-model.search([("user_id", "!=", False), ("date_end", "!=", False), ("estimated_remaining_hours", ">", 0.0)])._compute_planned_hour_ids()
+model.search([("user_id", "!=", False), ("planned_date_end", "!=", False), ("estimated_remaining_hours", ">", 0.0)])._compute_planned_hour_ids()
             </field>
             <field name="state">code</field>
             <field name="priority" eval="5" />
diff --git a/migrations/14.0.2.0.0/post-migration.py b/migrations/14.0.2.0.0/post-migration.py
index 2ceca1653a125c853aeae8f48e7760a469afdc5e..ec10fe26f62338665ec66eb5ce77a314e9c84aad 100644
--- a/migrations/14.0.2.0.0/post-migration.py
+++ b/migrations/14.0.2.0.0/post-migration.py
@@ -9,7 +9,7 @@ def _update_tasks_date_end(env):
         .with_context(active_test=False)
         .search([("date_deadline", "!=", False)])
     ):
-        task.date_end = task.date_deadline
+        task.planned_date_end = task.date_deadline
 
 
 def migrate(cr, version):
diff --git a/models/__init__.py b/models/__init__.py
index e724b7ff7ce59bd7e85329485d9e6dbfa10b5e4a..a43ea9130ec41113cad3b105ee6c26e510a1da09 100644
--- a/models/__init__.py
+++ b/models/__init__.py
@@ -1,5 +1,7 @@
 from . import account_analytic_line
 from . import account_move
 from . import project
+from . import project_overview
 from . import project_task_planned_hour
 from . import project_task
+from . import sale_order_line
diff --git a/models/project_overview.py b/models/project_overview.py
new file mode 100644
index 0000000000000000000000000000000000000000..8bf5011a081686c97ddffa3024fc8a0c134c82b5
--- /dev/null
+++ b/models/project_overview.py
@@ -0,0 +1,15 @@
+# Copyright 2021 Le Filament (<http://www.le-filament.com>)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from odoo import fields, models
+
+
+class Project(models.Model):
+    _inherit = "project.project"
+
+    def _get_late_tasks_domain(self):
+        return [
+            ("project_id", "in", self.ids),
+            ("date_deadline", "<", fields.Date.to_string(fields.Date.today())),
+            ("planned_date_end", "=", False),
+        ]
diff --git a/models/project_task.py b/models/project_task.py
index 6860d0fefb3d3ca8ef60c7e93ff5e1f69d28197d..ab940a0ca10e3074e4992e5d76f3ed924a964b50 100644
--- a/models/project_task.py
+++ b/models/project_task.py
@@ -69,11 +69,13 @@ class ProjectTask(models.Model):
             else:
                 task.estimated_remaining_hours = task.manual_remaining_hours
 
-    @api.depends("user_id", "date_start", "date_end", "estimated_remaining_hours")
+    @api.depends(
+        "user_id", "planned_date_start", "planned_date_end", "estimated_remaining_hours"
+    )
     def _compute_planned_hour_ids(self):
         """
-        Les heures planifiées par mois sont calculées à partir des heures restantes estimées
-        entre la date du jour et la date de fin,
+        Les heures planifiées par mois sont calculées à partir des heures restantes
+        estimées entre la date du jour et la date de fin,
         au prorata du nombre de jours dans chaque mois
         """
         date_today = fields.Date.today()
@@ -81,16 +83,19 @@ class ProjectTask(models.Model):
             task.planned_hour_ids.unlink()
             if (
                 task.user_id
-                and task.date_end
-                and task.date_end.date() >= date_today
+                and task.planned_date_end
+                and task.planned_date_end.date() >= date_today
                 and task.estimated_remaining_hours
                 and task.estimated_remaining_hours > 0.0
             ):
-                if task.date_start and task.date_start.date() > date_today:
-                    date_start = task.date_start.date()
+                if (
+                    task.planned_date_start
+                    and task.planned_date_start.date() > date_today
+                ):
+                    date_start = task.planned_date_start.date()
                 else:
                     date_start = date_today
-                date_end = task.date_end.date()
+                date_end = task.planned_date_end.date()
                 planned_hours_values = []
                 # Nombre de jours total entre les 2 dates (intervalle)
                 total_days = (date_end - date_start).days + 1
@@ -127,7 +132,8 @@ class ProjectTask(models.Model):
                                 * ((date_end - month_date).days + 1)
                                 / total_days
                             )
-                        # Pour tous les mois intermédiaires, on calcule sur la totalité du mois
+                        # Pour tous les mois intermédiaires, on calcule sur la totalité
+                        # du mois
                         else:
                             planned_hours = (
                                 task.estimated_remaining_hours
diff --git a/models/project_task_planned_hour.py b/models/project_task_planned_hour.py
index 317491c89b0365fbda09bd510e574e55d865847f..d85558ea02b528d7d54e533765d63ff022b78793 100644
--- a/models/project_task_planned_hour.py
+++ b/models/project_task_planned_hour.py
@@ -15,10 +15,14 @@ class ProjectTaskPlannedHour(models.Model):
         ondelete="cascade",
         required=1,
     )
+    project_id = fields.Many2one(related="task_id.project_id", store=True)
     user_id = fields.Many2one(
         comodel_name="res.users",
         string="Assigné à",
         required=1,
     )
+    department_id = fields.Many2one(
+        related="user_id.employee_id.department_id", store=True
+    )
     planned_hours = fields.Float(string="Heures planifiées")
     date_month = fields.Date("Mois")
diff --git a/models/sale_order_line.py b/models/sale_order_line.py
new file mode 100644
index 0000000000000000000000000000000000000000..eac8141b30ff4d5ef2e7b0939e705dcff1a581c6
--- /dev/null
+++ b/models/sale_order_line.py
@@ -0,0 +1,17 @@
+# © 2018- Le Filament (https://le-filament.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from odoo import models
+
+
+class SaleOrderLine(models.Model):
+    _inherit = "sale.order.line"
+
+    # ------------------------------------------------------
+    # Override parent
+    # ------------------------------------------------------
+    def _timesheet_create_task_prepare_values(self, project):
+        values = super()._timesheet_create_task_prepare_values(project)
+        values["planned_date_end"] = self.estimated_date_invoice
+        values["date_deadline"] = self.estimated_date_invoice
+        return values
diff --git a/views/project_task.xml b/views/project_task.xml
index cbb7c749258ddafa24ae98e5977c9f0d8a08f061..830d125fddab06ebcbe7851e7113b1fc87733093 100644
--- a/views/project_task.xml
+++ b/views/project_task.xml
@@ -13,7 +13,7 @@
                     <filter
                         string="A renseigner"
                         name="to_fill"
-                        domain="['|', '|', '|', ('user_id', '=', []), ('date_start', '=', False), ('date_end', '=', False), ('planned_hours', '=', 0.0)]"
+                        domain="['|', '|', '|', ('user_id', '=', []), ('planned_date_start', '=', False), ('planned_date_end', '=', False), ('planned_hours', '=', 0.0)]"
                     />
                     <separator />
                 </xpath>
@@ -30,8 +30,8 @@
                     <attribute name="groups">base.group_no_one</attribute>
                 </field>
                 <field name="date_deadline" position="after">
-                    <field name="date_start" widget="date" />
-                    <field name="date_end" widget="date" />
+                    <field name="planned_date_start" widget="date" />
+                    <field name="planned_date_end" widget="date" />
                 </field>
 
                 <xpath expr="//field[@name='planned_hours']" position="after">
@@ -134,8 +134,12 @@
                             optional="hide"
                             attrs="{'invisible': [('is_closed', '=', True)]}"
                         />
-                        <field name="date_start" optional="show" widget="date" />
-                        <field name="date_end" optional="show" widget="date" />
+                        <field
+                            name="planned_date_start"
+                            optional="show"
+                            widget="date"
+                        />
+                        <field name="planned_date_end" optional="show" widget="date" />
                         <field
                             name="tag_ids"
                             widget="many2many_tags"
@@ -186,15 +190,15 @@
                 <xpath expr="//calendar" position="attributes">
                     <attribute name="mode" />
                     <attribute name="js_class" />
-                    <attribute name="date_start">date_start</attribute>
-                    <attribute name="date_stop">date_end</attribute>
+                    <attribute name="date_start">planned_date_start</attribute>
+                    <attribute name="date_stop">planned_date_end</attribute>
                 </xpath>
                 <field name="date_deadline" position="attributes">
                     <attribute name="groups">base.group_no_one</attribute>
                 </field>
                 <field name="date_deadline" position="after">
-                    <field name="date_start" widget="date" />
-                    <field name="date_end" widget="date" />
+                    <field name="planned_date_start" widget="date" />
+                    <field name="planned_date_end" widget="date" />
                 </field>
             </field>
         </record>
@@ -210,7 +214,7 @@
                     <attribute name="groups">base.group_no_one</attribute>
                 </field>
                 <field name="date_deadline" position="after">
-                    <field name="date_end" widget="remaining_days" />
+                    <field name="planned_date_end" widget="remaining_days" />
                 </field>
             </field>
         </record>
diff --git a/views/project_task_planned_hour.xml b/views/project_task_planned_hour.xml
index 98d99eb24cd140d89eb4726f5c99d6f400fe484d..19a81da87ab254628ae95e5990168a63ac0dcaa7 100644
--- a/views/project_task_planned_hour.xml
+++ b/views/project_task_planned_hour.xml
@@ -13,7 +13,9 @@
                         <group>
                             <group>
                                 <field name="user_id" />
+                                <field name="department_id" />
                                 <field name="task_id" />
+                                <field name="project_id" />
                             </group>
                             <group>
                                 <field name="date_month" />
@@ -32,7 +34,9 @@
                 <tree string="Heures planifiées" create="0" edit="0" delete="0">
                     <field name="date_month" />
                     <field name="user_id" />
+                    <field name="department_id" />
                     <field name="task_id" />
+                    <field name="project_id" />
                     <field name="planned_hours" widget="float_time" />
                 </tree>
             </field>
@@ -88,6 +92,30 @@
                             domain="[]"
                             context="{'group_by':'date_month'}"
                         />
+                        <filter
+                            string="Utilisateur"
+                            name="group_user"
+                            domain="[]"
+                            context="{'group_by':'user_id'}"
+                        />
+                        <filter
+                            string="Département"
+                            name="group_department"
+                            domain="[]"
+                            context="{'group_by':'department_id'}"
+                        />
+                        <filter
+                            string="Tâche"
+                            name="group_task"
+                            domain="[]"
+                            context="{'group_by':'task_id'}"
+                        />
+                        <filter
+                            string="Projet"
+                            name="group_project"
+                            domain="[]"
+                            context="{'group_by':'project_id'}"
+                        />
                     </group>
                 </search>
             </field>