Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions portal_timesheet_input/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
====================================
Portal Timesheet Input (Weekly Grid)
====================================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:1eb6bbd525cd36ac479604fe6d523e91261004070d1612beb7881f6bb2800f71
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Ftimesheet-lightgray.png?logo=github
:target: https://github.com/OCA/timesheet/tree/18.0/portal_timesheet_input
:alt: OCA/timesheet
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/timesheet-18-0/timesheet-18-0-portal_timesheet_input
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/timesheet&target_branch=18.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

Allows portal users to enter timesheets using a weekly grid view,
similar to the Enterprise Timesheet Grid.

**Table of contents**

.. contents::
:local:

Usage
=====

1. Log in as a portal user.
2. Go to the "Timesheets" menu (configured in your portal).
3. Access the weekly grid view to enter your time.

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/timesheet/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/timesheet/issues/new?body=module:%20portal_timesheet_input%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* Acysos S.L.

Maintainers
-----------

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

This module is part of the `OCA/timesheet <https://github.com/OCA/timesheet/tree/18.0/portal_timesheet_input>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
3 changes: 3 additions & 0 deletions portal_timesheet_input/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright 2025 Acysos S.L. (https://www.acysos.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import controllers
26 changes: 26 additions & 0 deletions portal_timesheet_input/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright 2025 Acysos S.L. (https://www.acysos.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Portal Timesheet Input (Weekly Grid)",
"summary": "Portal Weekly Timesheet Grid Entry",
"author": "Acysos S.L., Odoo Community Association (OCA)",
"website": "https://github.com/OCA/timesheet",
"category": "Operations/Timesheets",
"version": "18.0.1.0.0",
"depends": ["portal", "hr_timesheet", "project"],
"data": [
"security/ir.model.access.csv",
"security/security.xml",
"views/portal_templates.xml",
"views/hr_employee_views.xml",
],
"assets": {
"web.assets_frontend": [
"portal_timesheet_input/static/src/js/timesheet_input.esm.js",
],
},
"license": "AGPL-3",
"installable": True,
"application": False,
"auto_install": False,
}
3 changes: 3 additions & 0 deletions portal_timesheet_input/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright 2025 Acysos S.L. (https://www.acysos.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import main
200 changes: 200 additions & 0 deletions portal_timesheet_input/controllers/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# Copyright 2025 Acysos S.L. (https://www.acysos.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from datetime import date, datetime, timedelta

from odoo import _, http
from odoo.http import request

from odoo.addons.hr_timesheet.controllers.portal import TimesheetCustomerPortal


class PortalTimesheetInput(TimesheetCustomerPortal):
"""Portal Timesheet Input Controller."""

@http.route(["/my/timesheet/input"], type="http", auth="user", website=True)
def timesheet_input_grid(self, week_start=None, **kwargs):
"""Render the timesheet input grid page."""
# 1. Determine Week Range
today = date.today()
if week_start:
try:
start_date = datetime.strptime(week_start, "%Y-%m-%d").date()
except ValueError:
start_date = today - timedelta(days=today.weekday())
else:
# Monday of current week
start_date = today - timedelta(days=today.weekday())

end_date = start_date + timedelta(days=6)

# 2. Prepare Days Header
days = []
current = start_date
while current <= end_date:
days.append(
{
"date": current,
"name": current.strftime("%a %d"),
"day_str": current.strftime("%Y-%m-%d"),
}
)
current += timedelta(days=1)

# 3. Fetch Data
partner = request.env.user.partner_id
employee = (
request.env["hr.employee"]
.sudo()
.search([("user_id", "=", request.env.user.id)], limit=1)
)
if not employee:
return request.render(
"portal_timesheet_input.timesheet_error",
{
"page_name": "timesheet_input",
"error_message": _(
"You must be linked to an employee to enter timesheets."
),
},
)

domain = [
("employee_id", "=", employee.id),
("date", ">=", start_date),
("date", "<=", end_date),
# Ensure project is set
("project_id", "!=", False),
]
timesheets = request.env["account.analytic.line"].sudo().search(domain)
grid_data = {}

# Key can be 'task_ID' or 'proj_ID_no_task'
def get_key(line):
if line.task_id:
return f"task_{line.task_id.id}"
return f"proj_{line.project_id.id}"

# Pre-fill with existing entries
for line in timesheets:
key = get_key(line)
if key not in grid_data:
grid_data[key] = {
"type": "task" if line.task_id else "project",
"id": (line.task_id.id if line.task_id else line.project_id.id),
"name": (
line.task_id.name if line.task_id else line.project_id.name
),
"project_name": line.project_id.name,
"project_id": line.project_id.id,
"days": {},
}

day_str = line.date.strftime("%Y-%m-%d")
grid_data[key]["days"][day_str] = (
grid_data[key]["days"].get(day_str, 0.0) + line.unit_amount
)

# Convert grid_data to sorted list
rows = sorted(grid_data.values(), key=lambda x: (x["project_name"], x["name"]))

# Pass available projects/tasks for the "Add Line" feature
# Standard portal domain for projects
projects = (
request.env["project.project"]
.sudo()
.search(
[
("privacy_visibility", "=", "portal"),
(
"message_partner_ids",
"child_of",
[partner.commercial_partner_id.id],
),
]
)
)

values = {
"page_name": "timesheet_input_grid",
"week_start": start_date.strftime("%Y-%m-%d"),
"prev_week": (start_date - timedelta(days=7)).strftime("%Y-%m-%d"),
"next_week": (start_date + timedelta(days=7)).strftime("%Y-%m-%d"),
"days": days,
"rows": rows,
"projects": projects,
}
return request.render("portal_timesheet_input.timesheet_input_grid", values)

@http.route(["/my/timesheet/save_grid"], type="json", auth="user", website=True)
def save_grid_inputs(self, inputs, week_start):
"""
inputs: list of {
'project_id': int, 'task_id': int (opt),
'date': str, 'unit_amount': float
}
"""
employee = (
request.env["hr.employee"]
.sudo()
.search([("user_id", "=", request.env.user.id)], limit=1)
)
if not employee:
return {"error": "User not linked to employee"}

# We will process cell by cell.
timesheet_sudo = request.env["account.analytic.line"].sudo()

for entry in inputs:
try:
date_input = entry.get("date")
date_obj = datetime.strptime(date_input, "%Y-%m-%d").date()
amount = float(entry.get("unit_amount") or 0.0)
project_id = int(entry.get("project_id"))
task_id = int(entry.get("task_id")) if entry.get("task_id") else False

domain = [
("employee_id", "=", employee.id),
("date", "=", date_obj),
("project_id", "=", project_id),
("task_id", "=", task_id),
]

existing = timesheet_sudo.search(domain)

if amount == 0.0:
if existing:
existing.unlink()
else:
if existing:
current_total = sum(existing.mapped("unit_amount"))
if current_total != amount:
diff = amount - current_total
existing[-1].write(
{"unit_amount": existing[-1].unit_amount + diff}
)

else:
vals = {
"project_id": project_id,
"task_id": task_id,
"date": date_obj,
"unit_amount": amount,
"employee_id": employee.id,
"name": "/", # Default description
}
timesheet_sudo.create(vals)
except (ValueError, IndexError, TypeError) as e:
return {"error": str(e)}

return {"success": True}

@http.route(["/my/timesheet/get_tasks"], type="json", auth="user", website=True)
def get_project_tasks(self, project_id):
"""Retrieve tasks for a specific project."""
domain = [
("project_id", "=", int(project_id)),
# Add visibility checks similar to search
("project_id.privacy_visibility", "=", "portal"),
]
tasks = request.env["project.task"].sudo().search(domain)
return [{"id": t.id, "name": t.name} for t in tasks]
Loading