diff options
| author | Odoo's Mergebot <robodoo@odoo.com> | 2020-12-21 18:31:44 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-12-21 18:31:44 +0100 |
| commit | cde5aa70bfa2592364a2126ef281552046f98ce9 (patch) | |
| tree | 2d45a1b3e0652ce944a0e0906293f5e4c8318fb5 | |
| parent | f5c0990c6af2f44f503de1cc50cc5b094e31976a (diff) | |
| parent | 171260006af954245cde3f53b6eaa2d230316529 (diff) | |
[MERGE] hr_timesheet: Fix global UX
### Goal of the PR
This PR is in the continuity of odoo#62360.
### Details
- Display remaining hours in task sales_line_id (name_get + task form view)
- Remove internal reference from services demo data
- Display sol in project timesheets list if project is billable
- Remove use of non_allow_billable in timesheets as it has been removed from project and task in previous PR (see above)
- Only recompute planned_hours for service product
- Determine the correct SOL for timesheet
- Set the last SOL of customer on timesheet if none is set on task or project
- Allow edition of so_line in timesheet
- Restrict SOL on project to sale lines with a service product
- Use same widget on partner_id many2One than in sale.order (using ranking)
- Remove timesheets table in SO and invoice portal.They were added in previous PR (see above). This introduces the use of links to /my/timesheets/.
- Review portal timesheets (my/timesheets/ and link from orders and invoices)
- Activate group_uom "Units of Measure" on sale_timesheet install
- Hide partner phone number in task form view
- Only determine SOL of task and timesheet if allow_billable=True
- Filter SOL in task so that it matches the SOL of the project SO
- Display red label if remaining hours is negative
- Change SO compute behavior
- Only open SO for salesman on project overview
task-2409761
--
I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr
closes odoo/odoo#62900
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
34 files changed, 678 insertions, 226 deletions
diff --git a/addons/event_sale/data/event_sale_data.xml b/addons/event_sale/data/event_sale_data.xml index bf8bc78249e..725bc51f757 100644 --- a/addons/event_sale/data/event_sale_data.xml +++ b/addons/event_sale/data/event_sale_data.xml @@ -14,7 +14,6 @@ <field name="uom_po_id" ref="uom.product_uom_unit"/> <field name="name">Event Registration</field> <field name="description_sale" eval="False"/> - <field name="default_code">EVENT_REG</field> <field name="categ_id" ref="event_sale.product_category_events"/> <field name="type">service</field> </record> diff --git a/addons/event_sale/models/event_ticket.py b/addons/event_sale/models/event_ticket.py index 7759a547d84..da142f973c9 100644 --- a/addons/event_sale/models/event_ticket.py +++ b/addons/event_sale/models/event_ticket.py @@ -72,8 +72,6 @@ class EventTemplateTicket(models.Model): 'list_price': 0, 'standard_price': 0, 'type': 'service', - 'default_code': 'EVENT_REG', - 'type': 'service', }).id self.env['ir.model.data'].create({ 'name': 'product_product_event', diff --git a/addons/event_sale/static/tests/tours/event_configurator_ui.js b/addons/event_sale/static/tests/tours/event_configurator_ui.js index 1170a142a09..a7419871bbe 100644 --- a/addons/event_sale/static/tests/tours/event_configurator_ui.js +++ b/addons/event_sale/static/tests/tours/event_configurator_ui.js @@ -20,10 +20,10 @@ tour.register('event_configurator_tour', { }, { trigger: 'div[name="product_id"] input, div[name="product_template_id"] input', run: function (actions) { - actions.text('EVENT'); + actions.text('Event'); } }, { - trigger: 'ul.ui-autocomplete a:contains("EVENT")', + trigger: 'ul.ui-autocomplete a:contains("Event")', run: 'click' }, { trigger: 'div[name="event_id"] input', @@ -56,7 +56,7 @@ tour.register('event_configurator_tour', { trigger: 'ul.nav a:contains("Order Lines")', run: 'click' }, { - trigger: 'td:contains("EVENT")', + trigger: 'td:contains("Event")', run: 'click' }, { trigger: '.o_edit_product_configuration' diff --git a/addons/hr_expense/data/hr_expense_data.xml b/addons/hr_expense/data/hr_expense_data.xml index 5133dc6d8a8..5005c238c44 100644 --- a/addons/hr_expense/data/hr_expense_data.xml +++ b/addons/hr_expense/data/hr_expense_data.xml @@ -6,7 +6,6 @@ <field name="list_price">0.0</field> <field name="standard_price">1.0</field> <field name="type">service</field> - <field name="default_code">EXP_GEN</field> <field name="categ_id" ref="product.cat_expense"/> <field name="can_be_expensed" eval="True"/> </record> diff --git a/addons/hr_expense/data/hr_expense_demo.xml b/addons/hr_expense/data/hr_expense_demo.xml index f881e5a6468..5ce0198e0e4 100644 --- a/addons/hr_expense/data/hr_expense_demo.xml +++ b/addons/hr_expense/data/hr_expense_demo.xml @@ -37,7 +37,6 @@ <field name="standard_price">0.32</field> <field name="type">service</field> <field name="name">Car Travel Expenses</field> - <field name="default_code">EXP_CT</field> <field name="uom_id" ref="uom.product_uom_km"/> <field name="uom_po_id" ref="uom.product_uom_km"/> <field name="categ_id" ref="product.cat_expense"/> @@ -50,7 +49,6 @@ <field name="standard_price">700.0</field> <field name="type">service</field> <field name="name">Air Flight</field> - <field name="default_code">EXP_AF</field> <field name="categ_id" ref="product.cat_expense"/> <field name="can_be_expensed" eval="True" /> <field name="image_1920" type="base64" file="hr_expense/static/img/air_ticket-image.jpg"/> diff --git a/addons/hr_timesheet/controllers/portal.py b/addons/hr_timesheet/controllers/portal.py index 8127b83be6f..b244ee3e656 100644 --- a/addons/hr_timesheet/controllers/portal.py +++ b/addons/hr_timesheet/controllers/portal.py @@ -23,32 +23,59 @@ class TimesheetCustomerPortal(CustomerPortal): values['timesheet_count'] = request.env['account.analytic.line'].sudo().search_count(domain) return values + def _get_searchbar_inputs(self): + return { + 'all': {'input': 'all', 'label': _('Search in All')}, + 'project': {'input': 'project', 'label': _('Search in Project')}, + 'name': {'input': 'name', 'label': _('Search in Description')}, + 'employee': {'input': 'employee', 'label': _('Search in Employee')}, + 'task': {'input': 'task', 'label': _('Search in Task')} + } + + def _get_searchbar_groupby(self): + return { + 'none': {'input': 'none', 'label': _('None')}, + 'project': {'input': 'project', 'label': _('Project')}, + 'task': {'input': 'task', 'label': _('Task')}, + 'date': {'input': 'date', 'label': _('Date')}, + 'employee': {'input': 'employee', 'label': _('Employee')} + } + + def _get_search_domain(self, search_in, search): + search_domain = [] + if search_in in ('project', 'all'): + search_domain = OR([search_domain, [('project_id', 'ilike', search)]]) + if search_in in ('name', 'all'): + search_domain = OR([search_domain, [('name', 'ilike', search)]]) + if search_in in ('employee', 'all'): + search_domain = OR([search_domain, [('employee_id', 'ilike', search)]]) + if search_in in ('task', 'all'): + search_domain = OR([search_domain, [('task_id', 'ilike', search)]]) + return search_domain + + def _get_groupby_mapping(self): + return { + 'project': 'project_id', + 'task': 'task_id', + 'employee': 'employee_id', + 'date': 'date' + } + @http.route(['/my/timesheets', '/my/timesheets/page/<int:page>'], type='http', auth="user", website=True) def portal_my_timesheets(self, page=1, sortby=None, filterby=None, search=None, search_in='all', groupby='none', **kw): Timesheet_sudo = request.env['account.analytic.line'].sudo() values = self._prepare_portal_layout_values() domain = request.env['account.analytic.line']._timesheet_get_portal_domain() + _items_per_page = 100 searchbar_sortings = { 'date': {'label': _('Newest'), 'order': 'date desc'}, 'name': {'label': _('Description'), 'order': 'name'}, } - searchbar_inputs = { - 'all': {'input': 'all', 'label': _('Search in All')}, - 'project': {'input': 'project', 'label': _('Search in Project')}, - 'name': {'input': 'name', 'label': _('Search in Description')}, - 'employee': {'input': 'employee', 'label': _('Search in Employee')}, - 'task': {'input': 'task', 'label': _('Search in Task')}, - } + searchbar_inputs = self._get_searchbar_inputs() - searchbar_groupby = { - 'none': {'input': 'none', 'label': _('None')}, - 'project': {'input': 'project', 'label': _('Project')}, - 'task': {'input': 'task', 'label': _('Task')}, - 'date': {'input': 'date', 'label': _('Date')}, - 'employee': {'input': 'employee', 'label': _('Employee')}, - } + searchbar_groupby = self._get_searchbar_groupby() today = fields.Date.today() quarter_start, quarter_end = date_utils.get_quarter(today) @@ -77,16 +104,7 @@ class TimesheetCustomerPortal(CustomerPortal): domain = AND([domain, searchbar_filters[filterby]['domain']]) if search and search_in: - search_domain = [] - if search_in in ('project', 'all'): - search_domain = OR([search_domain, [('project_id', 'ilike', search)]]) - if search_in in ('name', 'all'): - search_domain = OR([search_domain, [('name', 'ilike', search)]]) - if search_in in ('employee', 'all'): - search_domain = OR([search_domain, [('employee_id', 'ilike', search)]]) - if search_in in ('task', 'all'): - search_domain = OR([search_domain, [('task_id', 'ilike', search)]]) - domain += search_domain + domain += self._get_search_domain(search_in, search) timesheet_count = Timesheet_sudo.search_count(domain) # pager @@ -95,19 +113,14 @@ class TimesheetCustomerPortal(CustomerPortal): url_args={'sortby': sortby, 'search_in': search_in, 'search': search, 'filterby': filterby, 'groupby': groupby}, total=timesheet_count, page=page, - step=self._items_per_page + step=_items_per_page ) def get_timesheets(): - groupby_mapping = { - 'project': 'project_id', - 'task': 'task_id', - 'employee': 'employee_id', - 'date': 'date', - } + groupby_mapping = self._get_groupby_mapping() field = groupby_mapping.get(groupby, None) orderby = '%s, %s' % (field, order) if field else order - timesheets = Timesheet_sudo.search(domain, order=orderby, limit=self._items_per_page, offset=pager['offset']) + timesheets = Timesheet_sudo.search(domain, order=orderby, limit=_items_per_page, offset=pager['offset']) if field: if groupby == 'date': time_data = Timesheet_sudo.read_group(domain, ['date', 'unit_amount:sum'], ['date:day']) @@ -115,7 +128,7 @@ class TimesheetCustomerPortal(CustomerPortal): grouped_timesheets = [(Timesheet_sudo.concat(*g), mapped_time[k]) for k, g in groupbyelem(timesheets, itemgetter('date'))] else: time_data = time_data = Timesheet_sudo.read_group(domain, [field, 'unit_amount:sum'], [field]) - mapped_time = dict([(m[field][0], m['unit_amount']) for m in time_data]) + mapped_time = dict([(m[field][0] if m[field] else False, m['unit_amount']) for m in time_data]) grouped_timesheets = [(Timesheet_sudo.concat(*g), mapped_time[k.id]) for k, g in groupbyelem(timesheets, itemgetter(field))] return timesheets, grouped_timesheets diff --git a/addons/hr_timesheet/views/hr_timesheet_portal_templates.xml b/addons/hr_timesheet/views/hr_timesheet_portal_templates.xml index 90f2d679928..4385bbddd6a 100644 --- a/addons/hr_timesheet/views/hr_timesheet_portal_templates.xml +++ b/addons/hr_timesheet/views/hr_timesheet_portal_templates.xml @@ -36,24 +36,74 @@ <t t-foreach="grouped_timesheets" t-as="timesheets_with_hours"> <t t-set="timesheets" t-value="timesheets_with_hours[0]"/> <t t-set="hours_spent" t-value="timesheets_with_hours[1]"/> - <thead> + <thead style="font-size: 0.8rem"> <tr t-if="not groupby =='none'" t-attf-class="{{'thead-light'}}"> - <th t-if="groupby == 'project'" colspan="5"> - <em class="font-weight-normal text-muted">Timesheets for project:</em> - <span t-field="timesheets[0].project_id.name"/> - </th> - <th t-elif="groupby == 'task'" colspan="5"> - <em class="font-weight-normal text-muted">Timesheets for task:</em> - <span t-field="timesheets[0].task_id.name"/> - </th> - <th t-elif="groupby == 'date'" colspan="5"> - <em class="font-weight-normal text-muted">Timesheets on </em> - <span t-field="timesheets[0].date"/> - </th> - <th t-elif="groupby == 'employee'" colspan="5"> - <em class="font-weight-normal text-muted">Timesheets for employee:</em> - <span t-field="timesheets[0].employee_id.name"/> - </th> + <t t-if="groupby == 'project'"> + <th t-if="groupby == 'project'" colspan="5"> + <em class="font-weight-normal text-muted">Timesheets for project:</em> + <span t-field="timesheets[0].project_id.name"/> + </th> + <th colspan="1" class="text-right text-muted"> + <t t-if="is_uom_day"> + Total: <span class="text-muted" t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/> + </t> + <t t-else=""> + Total: <span class="text-muted" t-esc="hours_spent" t-options='{"widget": "float_time"}'/> + </t> + </th> + </t> + <t t-elif="groupby == 'task'"> + <th colspan="5"> + <em class="font-weight-normal text-muted">Timesheets for task:</em> + <span t-field="timesheets[0].task_id.name"/> + </th> + <th colspan="1" class="text-right text-muted"> + <t t-if="is_uom_day"> + Total: <span t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/> + </t> + <t t-else=""> + Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/> + </t> + </th> + </t> + <t t-elif="groupby == 'date'"> + <th colspan="5"> + <em class="font-weight-normal text-muted">Timesheets on </em> + <span t-field="timesheets[0].date"/> + </th> + <th colspan="1" class="text-right text-muted"> + <t t-if="is_uom_day"> + Total: <span t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/> + </t> + <t t-else=""> + Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/> + </t> + </th> + </t> + <t t-elif="groupby == 'employee'"> + <th colspan="5"> + <em class="font-weight-normal text-muted">Timesheets for employee:</em> + <span t-field="timesheets[0].employee_id.name"/> + </th> + <th colspan="1" class="text-right text-muted"> + <t t-if="is_uom_day"> + Total: <span t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/> + </t> + <t t-else=""> + Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/> + </t> + </th> + </t> + </tr> + <tr t-else=""> + <div style="text-align: right;" class="mr-2 mb-1 text-muted"> + <t t-if="is_uom_day"> + Total: <span t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/> + </t> + <t t-else=""> + Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/> + </t> + </div> </tr> <tr> <th t-if="not groupby == 'date'">Date</th> @@ -61,21 +111,21 @@ <th t-if="not groupby == 'project'">Project</th> <th t-if="not groupby == 'task'">Task</th> <th>Description</th> - <th t-if="is_uom_day" class="text-right">Days Spent <small class="text-muted">(Total: <span t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/>)</small></th> - <th t-else="" class="text-right">Hours Spent <small class="text-muted">(Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/>)</small></th> + <th t-if="is_uom_day" class="text-right">Days Spent</th> + <th t-else="" class="text-right">Hours Spent</th> </tr> </thead> - <tbody> + <tbody style="font-size: 0.8rem"> <t t-foreach="timesheets" t-as="timesheet"> <tr> <td t-if="not groupby == 'date'"><span t-field="timesheet.date" t-options='{"widget": "date"}'/></td> - <td t-if="not groupby == 'employee'"><span t-field="timesheet.employee_id"/></td> - <td t-if="not groupby == 'project'"><span t-field="timesheet.project_id"/></td> - <td t-if="not groupby == 'task'"><span t-field="timesheet.task_id"/></td> - <td><span t-esc="timesheet.name"/></td> + <td t-if="not groupby == 'employee'"><span t-field="timesheet.employee_id" t-att-title="timesheet.employee_id.display_name" /></td> + <td t-if="not groupby == 'project'"><span t-field="timesheet.project_id" t-att-title="timesheet.project_id.display_name"/></td> + <td t-if="not groupby == 'task'"><span t-field="timesheet.task_id" t-att-title="timesheet.task_id.display_name"/></td> + <td><span t-esc="timesheet.name" t-att-title="timesheet.name"/></td> <td class="text-right"> <span t-if="is_uom_day" t-esc="timesheet._get_timesheet_time_day()" t-options='{"widget": "timesheet_uom"}'/> - <span t-else= "" t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/> + <span t-else="" t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/> </td> </tr> </t> diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml index 2f77dacf67e..c7d4974abc1 100644 --- a/addons/portal/views/portal_templates.xml +++ b/addons/portal/views/portal_templates.xml @@ -299,7 +299,7 @@ </div> <t t-raw="0"/> </div> - <form t-if="searchbar_inputs" class="form-inline o_portal_search_panel ml-lg-4"> + <form t-if="searchbar_inputs" class="form-inline o_portal_search_panel ml-lg-4 col-xl-4 col-md-5"> <div class="input-group input-group-sm w-100"> <div class="input-group-prepend"> <button type="button" class="btn btn-secondary dropdown-toggle" data-toggle="dropdown"/> diff --git a/addons/product/data/product_demo.xml b/addons/product/data/product_demo.xml index 53d42a00af6..ba80566393e 100644 --- a/addons/product/data/product_demo.xml +++ b/addons/product/data/product_demo.xml @@ -37,7 +37,6 @@ <field name="list_price">14.0</field> <field name="standard_price">8.0</field> <field name="type">service</field> - <field name="default_code">EXP_REST</field> <field name="categ_id" ref="product.cat_expense"/> </record> @@ -46,7 +45,6 @@ <field name="list_price">400.0</field> <field name="standard_price">400.0</field> <field name="type">service</field> - <field name="default_code">EXP_HA</field> <field name="uom_id" ref="uom.product_uom_day"/> <field name="uom_po_id" ref="uom.product_uom_day"/> <field name="categ_id" ref="cat_expense"/> diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 843418ff4c9..0b8f1e2df6d 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -294,7 +294,7 @@ <group> <field name="active" invisible="1"/> <field name="user_id" string="Project Manager" widget="many2one_avatar_user" attrs="{'readonly':[('active','=',False)]}" domain="[('share', '=', False)]"/> - <field name="partner_id" string="Customer"/> + <field name="partner_id" string="Customer" widget="res_partner_many2one"/> <field name="partner_phone" widget="phone" attrs="{'invisible': [('partner_id', '=', False)]}"/> <field name="partner_email" widget="email" @@ -670,9 +670,9 @@ </group> <group> <field name="active" invisible="1"/> - <field name="partner_id" class="o_task_customer_field"/> + <field name="partner_id" widget="res_partner_many2one" class="o_task_customer_field"/> <field name="partner_email" widget="email" invisible="1"/> - <field name="partner_phone" widget="phone" attrs="{'invisible': [('partner_id', '=', False)]}"/> + <field name="partner_phone" widget="phone" attrs="{'invisible': True}"/> <field name="legend_blocked" invisible="1"/> <field name="legend_normal" invisible="1"/> <field name="legend_done" invisible="1"/> diff --git a/addons/sale/data/sale_demo.xml b/addons/sale/data/sale_demo.xml index d1026815fda..695736aae0e 100644 --- a/addons/sale/data/sale_demo.xml +++ b/addons/sale/data/sale_demo.xml @@ -687,7 +687,6 @@ Thanks!</field> <record id="advance_product_0" model="product.product"> <field name="name">Deposit</field> - <field name="default_code">Deposit</field> <field name="categ_id" ref="product.product_category_3"/> <field name="type">service</field> <field name="list_price">150.0</field> diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py index a5050f2f80f..adcddc144c6 100644 --- a/addons/sale_project/models/project.py +++ b/addons/sale_project/models/project.py @@ -12,10 +12,12 @@ class Project(models.Model): sale_line_id = fields.Many2one( 'sale.order.line', 'Sales Order Item', copy=False, - domain="[('is_expense', '=', False), ('order_id', '=', sale_order_id), ('state', 'in', ['sale', 'done']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", + domain="[('is_service', '=', True), ('is_expense', '=', False), ('order_id', '=', sale_order_id), ('state', 'in', ['sale', 'done']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", help="Sales order item to which the project is linked. Link the timesheet entry to the sales order item defined on the project. " "Only applies on tasks without sale order item defined, and if the employee is not in the 'Employee/Sales Order Item Mapping' of the project.") - sale_order_id = fields.Many2one('sale.order', 'Sales Order', domain="[('partner_id', '=', partner_id)]", copy=False, help="Sales order to which the project is linked.") + sale_order_id = fields.Many2one('sale.order', 'Sales Order', + domain="[('order_line.product_id.type', '=', 'service'), ('partner_id', '=', partner_id)]", + copy=False, help="Sales order to which the project is linked.") _sql_constraints = [ ('sale_order_required_if_sale_line', "CHECK((sale_line_id IS NOT NULL AND sale_order_id IS NOT NULL) OR (sale_line_id IS NULL))", 'The project should be linked to a sale order to select a sale order item.'), @@ -48,7 +50,7 @@ class ProjectTask(models.Model): task.partner_id = task.project_id.sale_line_id.order_partner_id super()._compute_partner_id() - @api.depends('partner_id.commercial_partner_id', 'sale_line_id.order_partner_id.commercial_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id') + @api.depends('commercial_partner_id', 'sale_line_id.order_partner_id.commercial_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id') def _compute_sale_line(self): for task in self: if not task.sale_line_id: diff --git a/addons/sale_project/models/sale_order.py b/addons/sale_project/models/sale_order.py index e779902dcc4..f974dbefabe 100644 --- a/addons/sale_project/models/sale_order.py +++ b/addons/sale_project/models/sale_order.py @@ -155,7 +155,7 @@ class SaleOrderLine(models.Model): # of a locked sale order. if 'product_uom_qty' in values and not self.env.context.get('no_update_planned_hours', False): for line in self: - if line.task_id: + if line.task_id and line.product_id.type == 'service': planned_hours = line._convert_qty_company_hours(line.task_id.company_id) line.task_id.write({'planned_hours': planned_hours}) return result diff --git a/addons/sale_project/views/project_task_views.xml b/addons/sale_project/views/project_task_views.xml index a779fea42e1..f8a76907ef3 100644 --- a/addons/sale_project/views/project_task_views.xml +++ b/addons/sale_project/views/project_task_views.xml @@ -13,6 +13,18 @@ </field> </record> + <record id="view_edit_project_inherit_form" model="ir.ui.view"> + <field name="name">project.project.view.inherit</field> + <field name="model">project.project</field> + <field name="inherit_id" ref="project.edit_project"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='partner_id']" position="attributes"> + <attribute name="options">{'always_reload': True}</attribute> + <attribute name="context">{'res_partner_search_mode': 'customer'}</attribute> + </xpath> + </field> + </record> + <record id="view_sale_project_inherit_form" model="ir.ui.view"> <field name="name">project.task.view.inherit</field> <field name="model">project.task</field> @@ -26,9 +38,13 @@ string="Sales Order" groups="sales_team.group_sale_salesman"/> </div> + <xpath expr="//field[@name='partner_id']" position="attributes"> + <attribute name="options">{'always_reload': True}</attribute> + <attribute name="context">{'res_partner_search_mode': 'customer'}</attribute> + </xpath> <xpath expr="//field[@name='partner_phone']" position="after"> - <field name="sale_line_id" string="Sales Order Item" attrs="{'invisible': [('partner_id', '=', False)]}" options='{"no_open": True}' readonly="1" context="{'create': False, 'edit': False, 'delete': False}" groups="sales_team.group_sale_salesman"/> - <field name="sale_order_id" attrs="{'invisible': [('project_id', '!=', False)]}" groups="sales_team.group_sale_salesman"/> + <field name="sale_order_id" attrs="{'invisible': True}" groups="sales_team.group_sale_salesman"/> + <field name="sale_line_id" string="Sales Order Item" attrs="{'invisible': [('partner_id', '=', False)]}" options='{"no_open": True}' readonly="1" context="{'create': False, 'edit': False, 'delete': False}"/> <field name="commercial_partner_id" invisible="1" /> <field name="project_sale_order_id" invisible="1"/> </xpath> diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index eb640411bdb..577e5cba62c 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -25,6 +25,7 @@ have real delivered quantities in sales orders. 'views/hr_timesheet_views.xml', 'views/res_config_settings_views.xml', 'views/hr_timesheet_templates.xml', + 'views/report_invoice.xml', 'views/sale_timesheet_portal_templates.xml', 'report/project_profitability_report_analysis_views.xml', 'data/sale_timesheet_filters.xml', diff --git a/addons/sale_timesheet/controllers/portal.py b/addons/sale_timesheet/controllers/portal.py index e4e04da4878..7d13914dc79 100644 --- a/addons/sale_timesheet/controllers/portal.py +++ b/addons/sale_timesheet/controllers/portal.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import http, _ from odoo.http import request from odoo.osv import expression from odoo.addons.account.controllers import portal +from odoo.addons.hr_timesheet.controllers.portal import TimesheetCustomerPortal class PortalAccount(portal.PortalAccount): @@ -38,3 +40,44 @@ class CustomerPortal(portal.CustomerPortal): values['timesheets'] = request.env['account.analytic.line'].sudo().search(domain) values['is_uom_day'] = request.env['account.analytic.line'].sudo()._is_timesheet_encode_uom_day() return values + + +class SaleTimesheetCustomerPortal(TimesheetCustomerPortal): + + def _get_searchbar_inputs(self): + searchbar_inputs = super()._get_searchbar_inputs() + searchbar_inputs.update( + sol={'input': 'sol', 'label': _('Search in Sales Order Item')}, + sol_id={'input': 'sol_id', 'label': _('Search in Sales Order Item ID')}, + invoice={'input': 'invoice_id', 'label': _('Search in Invoice ID')}) + return searchbar_inputs + + def _get_searchbar_groupby(self): + searchbar_groupby = super()._get_searchbar_groupby() + searchbar_groupby.update(sol={'input': 'sol', 'label': _('Sales Order Item')}) + return searchbar_groupby + + def _get_search_domain(self, search_in, search): + search_domain = super()._get_search_domain(search_in, search) + if search_in in ('sol', 'all'): + search_domain = expression.OR([search_domain, [('so_line', 'ilike', search)]]) + if search_in in ('sol_id', 'invoice_id'): + search = int(search) if search.isdigit() else 0 + if search_in == 'sol_id': + search_domain = expression.OR([search_domain, [('so_line.id', '=', search)]]) + if search_in == 'invoice_id': + invoice = request.env['account.move'].browse(search) + domain = request.env['account.analytic.line']._timesheet_get_sale_domain(invoice.mapped('invoice_line_ids.sale_line_ids'), invoice) + search_domain = expression.OR([search_domain, domain]) + return search_domain + + def _get_groupby_mapping(self): + groupby_mapping = super()._get_groupby_mapping() + groupby_mapping.update(sol='so_line') + return groupby_mapping + + @http.route(['/my/timesheets', '/my/timesheets/page/<int:page>'], type='http', auth="user", website=True) + def portal_my_timesheets(self, page=1, sortby=None, filterby=None, search=None, search_in='all', groupby='sol', **kw): + if search and search_in and search_in in ('sol_id', 'invoice_id') and not search.isdigit(): + search = '0' + return super().portal_my_timesheets(page, sortby, filterby, search, search_in, groupby, **kw) diff --git a/addons/sale_timesheet/data/sale_service_data.xml b/addons/sale_timesheet/data/sale_service_data.xml index b0042b6acdc..dbfcea047cc 100644 --- a/addons/sale_timesheet/data/sale_service_data.xml +++ b/addons/sale_timesheet/data/sale_service_data.xml @@ -5,8 +5,15 @@ <field name="name">Service on Timesheet</field> <field name="type">service</field> <field name="list_price">40</field> + <field name="uom_id" ref="uom.product_uom_hour"/> + <field name="uom_po_id" ref="uom.product_uom_hour"/> <field name="service_policy">delivered_timesheet</field> <field name="image_1920" type="base64" file="sale_timesheet/static/img/product_product_time_product.png"/> </record> </data> + <data> + <record model="res.groups" id="base.group_user"> + <field name="implied_ids" eval="[(4, ref('uom.group_uom'))]"/> + </record> + </data> </odoo> diff --git a/addons/sale_timesheet/data/sale_service_demo.xml b/addons/sale_timesheet/data/sale_service_demo.xml index f6aae634c63..3f1832b8a84 100644 --- a/addons/sale_timesheet/data/sale_service_demo.xml +++ b/addons/sale_timesheet/data/sale_service_demo.xml @@ -22,6 +22,7 @@ <field name="date_start" eval="time.strftime('%Y-%m-01 10:00:00')"/> <field name="name">After-Sales Services</field> <field name="analytic_account_id" ref="account_analytic_account_project_support"/> + <field name="allow_billable" eval="True" /> <field name="type_ids" eval="[(4, ref('project.project_stage_0')), (4, ref('project.project_stage_1')), (4, ref('project.project_stage_2'))]"/> </record> @@ -45,7 +46,6 @@ <record id="product_service_order_timesheet" model="product.product"> <field name="name">Customer Care (Prepaid Hours)</field> - <field name="default_code">SERV_585189</field> <field name="categ_id" ref="product.product_category_3"/> <field name="type">service</field> <field name="list_price">250.00</field> @@ -59,7 +59,6 @@ <record id="product_service_deliver_timesheet_1" model="product.product"> <field name="name">Senior Architect (Invoice on Timesheets)</field> - <field name="default_code">SERV_89744</field> <field name="categ_id" ref="product.product_category_3"/> <field name="list_price">200.00</field> <field name="standard_price">150.00</field> @@ -72,7 +71,6 @@ <record id="product_service_deliver_timesheet_2" model="product.product"> <field name="name">Junior Architect (Invoice on Timesheets)</field> - <field name="default_code">SERV_89665</field> <field name="categ_id" ref="product.product_category_3"/> <field name="list_price">100.00</field> <field name="standard_price">85.00</field> @@ -85,7 +83,6 @@ <record id="product_service_deliver_manual" model="product.product"> <field name="name">Kitchen Assembly (Milestones)</field> - <field name="default_code">SERV_32289</field> <field name="categ_id" ref="product.product_category_3"/> <field name="list_price">500</field> <field name="standard_price">420.00</field> diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index e946c30d18d..ef89280ab97 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -22,7 +22,9 @@ class AccountAnalyticLine(models.Model): ('non_billable_project', 'No task found')], string="Billable Type", compute='_compute_timesheet_invoice_type', compute_sudo=True, store=True, readonly=True) timesheet_invoice_id = fields.Many2one('account.move', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet") non_allow_billable = fields.Boolean("Non-Billable", help="Your timesheet will not be billed.") + so_line = fields.Many2one(compute="_compute_so_line", store=True, readonly=False) + # TODO: [XBO] Since the task_id is not required in this model, then it should more efficient to depends to bill_type and pricing_type of project (See in master) @api.depends('so_line.product_id', 'project_id', 'task_id', 'non_allow_billable', 'task_id.bill_type', 'task_id.pricing_type', 'task_id.non_allow_billable') def _compute_timesheet_invoice_type(self): non_allowed_billable = self.filtered('non_allow_billable') @@ -55,12 +57,21 @@ class AccountAnalyticLine(models.Model): else: self.so_line = False + @api.depends('task_id.sale_line_id', 'project_id.sale_line_id', 'employee_id', 'project_id.allow_billable') + def _compute_so_line(self): + for timesheet in self._get_not_billed(): # Get only the timesheets are not yet invoiced + timesheet.so_line = timesheet.project_id.allow_billable and timesheet._timesheet_determine_sale_line(timesheet.task_id, timesheet.employee_id, timesheet.project_id) + + def _get_not_billed(self): + return self.filtered(lambda t: not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel') + + def _check_timesheet_can_be_billed(self): + return self.so_line in self.project_id.mapped('sale_line_employee_ids.sale_line_id') | self.task_id.sale_line_id | self.project_id.sale_line_id + @api.constrains('so_line', 'project_id') def _check_sale_line_in_project_map(self): - for timesheet in self: - if timesheet.project_id and timesheet.so_line: # billed timesheet - if timesheet.so_line not in timesheet.project_id.mapped('sale_line_employee_ids.sale_line_id') | timesheet.task_id.sale_line_id | timesheet.project_id.sale_line_id: - raise ValidationError(_("This timesheet line cannot be billed: there is no Sale Order Item defined on the task, nor on the project. Please define one to save your timesheet line.")) + if not all(t._check_timesheet_can_be_billed() for t in self._get_not_billed().filtered(lambda t: t.project_id and t.so_line)): + raise ValidationError(_("This timesheet line cannot be billed: there is no Sale Order Item defined on the task, nor on the project. Please define one to save your timesheet line.")) def write(self, values): # prevent to update invoiced timesheets if one line is of type delivery @@ -81,26 +92,8 @@ class AccountAnalyticLine(models.Model): values['account_id'] = task.analytic_account_id.id values['company_id'] = task.analytic_account_id.company_id.id values = super(AccountAnalyticLine, self)._timesheet_preprocess(values) - # task implies so line (at create) - if any([field_name in values for field_name in ['task_id', 'project_id']]) and not values.get('so_line') and (values.get('employee_id') or self.mapped('employee_id')): - if not values.get('employee_id') and len(self.mapped('employee_id')) > 1: - raise UserError(_('You can not modify timesheets from different employees')) - task = self.env['project.task'].sudo().browse(values['task_id']) if values.get('task_id') else self.env['project.task'] - employee = self.env['hr.employee'].sudo().browse(values['employee_id']) if values.get('employee_id') else self.mapped('employee_id') - project = self.env['project.project'].sudo().browse(values['project_id']) if values.get('project_id') else task.project_id - values['so_line'] = self._timesheet_determine_sale_line(task, employee, project).id return values - def _timesheet_postprocess_values(self, values): - result = super(AccountAnalyticLine, self)._timesheet_postprocess_values(values) - # (re)compute the sale line - if any(field_name in values for field_name in ['task_id', 'employee_id', 'project_id']): - for timesheet in self: - result[timesheet.id].update({ - 'so_line': timesheet._timesheet_determine_sale_line(timesheet.task_id, timesheet.employee_id, timesheet.project_id).id, - }) - return result - @api.model def _timesheet_determine_sale_line(self, task, employee, project): """ Deduce the SO line associated to the timesheet line: @@ -108,19 +101,20 @@ class AccountAnalyticLine(models.Model): 2/ timesheet on employee rate task: find the SO line in the map of the project (even for subtask), or fallback on the SO line of the task, or fallback on the one on the project """ - if project.sale_line_id and not task: + if not task: if project.bill_type == 'customer_project' and project.pricing_type == 'employee_rate': map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', project.id), ('employee_id', '=', employee.id)]) if map_entry: return map_entry.sale_line_id - return project.sale_line_id + if project.sale_line_id: + return project.sale_line_id if task.allow_billable: if task.bill_type == 'customer_task': return task.sale_line_id if task.pricing_type == 'fixed_rate': return task.sale_line_id elif task.pricing_type == 'employee_rate' and not task.non_allow_billable: - map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', task.project_id.id), ('employee_id', '=', employee.id)]) + map_entry = project.sale_line_employee_ids.filtered(lambda map_entry: map_entry.employee_id == employee) if map_entry: return map_entry.sale_line_id if task.sale_line_id or project.sale_line_id: @@ -137,6 +131,9 @@ class AccountAnalyticLine(models.Model): @api.model def _timesheet_get_sale_domain(self, order_lines_ids, invoice_ids): + if not invoice_ids: + return [('so_line', 'in', order_lines_ids.ids)] + return [ '|', '&', diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index a09a31d44d0..35d4751731e 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -47,6 +47,7 @@ class Project(models.Model): ('service_type', '=', 'timesheet'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]""", help='Select a Service product with which you would like to bill your time spent on tasks.', + compute="_compute_timesheet_product_id", store=True, readonly=False, default=_default_timesheet_product_id) warning_employee_rate = fields.Boolean(compute='_compute_warning_employee_rate') @@ -231,6 +232,28 @@ class ProjectTask(models.Model): # TODO: [XBO] remove me in master non_allow_billable = fields.Boolean("Non-Billable", help="Your timesheets linked to this task will not be billed.") + remaining_hours_so = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours_so') + remaining_hours_available = fields.Boolean(related="sale_line_id.remaining_hours_available") + + @api.depends('sale_line_id', 'timesheet_ids', 'timesheet_ids.unit_amount') + def _compute_remaining_hours_so(self): + # TODO This is not yet perfectly working as timesheet.so_line stick to its old value although changed + # in the task From View. + timesheets = self.timesheet_ids.filtered(lambda t: t.task_id.sale_line_id in (t.so_line, t._origin.so_line) and t.so_line.remaining_hours_available) + + mapped_remaining_hours = {task._origin.id: task.sale_line_id and task.sale_line_id.remaining_hours or 0.0 for task in self} + uom_hour = self.env.ref('uom.product_uom_hour') + for timesheet in timesheets: + delta = 0 + if timesheet._origin.so_line == timesheet.task_id.sale_line_id: + delta += timesheet._origin.unit_amount + if timesheet.so_line == timesheet.task_id.sale_line_id: + delta -= timesheet.unit_amount + if delta: + mapped_remaining_hours[timesheet.task_id._origin.id] += timesheet.so_line.product_uom._compute_quantity(delta, uom_hour) + + for task in self: + task.remaining_hours_so = mapped_remaining_hours[task._origin.id] @api.depends( 'allow_billable', 'allow_timesheets', 'sale_order_id') @@ -259,17 +282,25 @@ class ProjectTask(models.Model): for task in self: task.analytic_account_active = task.analytic_account_active or task.analytic_account_id.active - @api.depends('sale_line_id', 'project_id', 'allow_billable', 'bill_type', 'pricing_type', 'non_allow_billable') + @api.depends('sale_line_id', 'project_id', 'allow_billable', 'non_allow_billable') def _compute_sale_order_id(self): for task in self: - if task.allow_billable and task.bill_type == 'customer_project' and task.pricing_type == 'employee_rate' and task.non_allow_billable: - task.sale_order_id = False - elif task.allow_billable and task.bill_type == 'customer_project': - task.sale_order_id = task.project_id.sale_order_id - elif task.allow_billable and task.bill_type == 'customer_task': - task.sale_order_id = task.sale_line_id.sudo().order_id - elif not task.sale_order_id: + if not task.allow_billable or task.non_allow_billable: task.sale_order_id = False + elif task.allow_billable: + if task.sale_line_id: + task.sale_order_id = task.sale_line_id.sudo().order_id + elif task.project_id.sale_order_id: + task.sale_order_id = task.project_id.sale_order_id + if task.sale_order_id and not task.partner_id: + task.partner_id = task.sale_order_id.partner_id + + @api.depends('commercial_partner_id', 'sale_line_id.order_partner_id.commercial_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'allow_billable') + def _compute_sale_line(self): + billable_tasks = self.filtered('allow_billable') + super(ProjectTask, billable_tasks)._compute_sale_line() + for task in billable_tasks.filtered(lambda t: not t.sale_line_id): + task.sale_line_id = task._get_last_sol_of_customer() @api.depends('project_id.sale_line_employee_ids') def _compute_is_project_map_empty(self): @@ -296,22 +327,6 @@ class ProjectTask(models.Model): project_dest = self.env['project.project'].browse(values['project_id']) if project_dest.bill_type == 'customer_project' and project_dest.pricing_type == 'employee_rate': self.write({'sale_line_id': False}) - if 'sale_line_id' in values and self.filtered('allow_timesheets').sudo().timesheet_ids: - so = self.env['sale.order.line'].browse(values['sale_line_id']).order_id - if so and not so.analytic_account_id: - so.analytic_account_id = self.project_id.analytic_account_id - timesheet_ids = self.filtered('allow_timesheets').timesheet_ids.filtered( - lambda t: (not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel') - ) - timesheet_ids.write({'so_line': values['sale_line_id']}) - if 'project_id' in values: - - # Special case when we edit SOL an project in same time, as we edit SOL of - # timesheet lines, function '_get_timesheet' won't find the right timesheet - # to edit so we must edit those here. - project = self.env['project.project'].browse(values.get('project_id')) - if project.allow_timesheets: - timesheet_ids.write({'project_id': values.get('project_id')}) if 'non_allow_billable' in values and self.filtered('allow_timesheets').sudo().timesheet_ids: timesheet_ids = self.filtered('allow_timesheets').timesheet_ids.filtered( lambda t: (not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel') @@ -329,6 +344,20 @@ class ProjectTask(models.Model): return res + def _get_last_sol_of_customer(self): + # Get the last SOL made for the customer in the current task where we need to compute + self.ensure_one() + if not self.commercial_partner_id or not self.allow_billable: + return False + domain = [('is_service', '=', True), ('order_partner_id', 'child_of', self.commercial_partner_id.id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done'])] + if self.project_id.bill_type == 'customer_type' and self.project_sale_order_id: + domain.append(('order_id', '=?', self.project_sale_order_id.id)) + sale_lines = self.env['sale.order.line'].search(domain) + for line in sale_lines: + if line.remaining_hours_available and line.remaining_hours > 0: + return line + return False + def action_make_billable(self): return { "name": _("Create Sales Order"), diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index e6d13265f36..af066c44cd1 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import api, fields, models, _ from odoo.osv import expression +import math class SaleOrder(models.Model): @@ -78,6 +79,66 @@ class SaleOrderLine(models.Model): qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')]) analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense) + remaining_hours_available = fields.Boolean(compute='_compute_remaining_hours_available') + remaining_hours = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours') + + def name_get(self): + res = super(SaleOrderLine, self).name_get() + if self.env.context.get('with_remaining_hours'): + names = dict(res) + result = [] + uom_hour = self.env.ref('uom.product_uom_hour') + uom_day = self.env.ref('uom.product_uom_day') + for line in self: + name = names.get(line.id) + if line.remaining_hours_available: + company = self.env.company + encoding_uom = company.timesheet_encode_uom_id + remaining_time = '' + if encoding_uom == uom_hour: + hours, minutes = divmod(abs(line.remaining_hours) * 60, 60) + round_minutes = minutes / 30 + minutes = math.ceil(round_minutes) if line.remaining_hours >= 0 else math.floor(round_minutes) + if minutes > 1: + minutes = 0 + hours += 1 + else: + minutes = minutes * 30 + remaining_time =' ({sign}{hours:02.0f}:{minutes:02.0f})'.format( + sign='-' if line.remaining_hours < 0 else '', + hours=hours, + minutes=minutes) + elif encoding_uom == uom_day: + remaining_days = company.project_time_mode_id._compute_quantity(line.remaining_hours, encoding_uom, round=False) + remaining_time = ' ({qty:.02f} {unit})'.format( + qty=remaining_days, + unit=_('days') if abs(remaining_days) > 1 else _('day') + ) + name = '{name}{remaining_time}'.format( + name=name, + remaining_time=remaining_time + ) + result.append((line.id, name)) + return result + return res + + @api.depends('product_id.service_policy') + def _compute_remaining_hours_available(self): + uom_hour = self.env.ref('uom.product_uom_hour') + for line in self: + is_ordered_timesheet = line.product_id.service_policy == 'ordered_timesheet' + is_time_product = line.product_uom.category_id == uom_hour.category_id + line.remaining_hours_available = is_ordered_timesheet and is_time_product + + @api.depends('qty_delivered', 'product_uom_qty', 'analytic_line_ids') + def _compute_remaining_hours(self): + uom_hour = self.env.ref('uom.product_uom_hour') + for line in self: + remaining_hours = None + if line.remaining_hours_available: + qty_left = line.product_uom_qty - line.qty_delivered + remaining_hours = line.product_uom._compute_quantity(qty_left, uom_hour) + line.remaining_hours = remaining_hours @api.depends('product_id') def _compute_qty_delivered_method(self): diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml index 6a44f41789b..1715f3c2735 100644 --- a/addons/sale_timesheet/views/hr_timesheet_templates.xml +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -404,9 +404,14 @@ <span t-if="foldable" t-att-class="('fa fa-caret-down' if row_type == 'sale_order' else 'fa fa-caret-right') + (' project_overview_foldable' if foldable else '')" style="cursor: pointer;" t-att-data-model="row[0].get('res_model')" t-att-data-res-id="row[0].get('res_id')"/> <t t-if="row_type == 'sale_order'"> - <a type="action" t-att-data-model="row_value['res_model']" t-att-data-res-id="row_value['res_id']" t-att-class="'o_timesheet_plan_redirect' if row_value['res_id'] else ''"> + <t t-if="env.user.has_group('sales_team.group_sale_salesman')"> + <a type="action" t-att-data-model="row_value['res_model']" t-att-data-res-id="row_value['res_id']" t-att-class="'o_timesheet_plan_redirect' if row_value['res_id'] else ''"> + <t t-esc="row_value.get('label')"/> + </a> + </t> + <t t-else=""> <t t-esc="row_value.get('label')"/> - </a> + </t> <span t-if="row_value.get('canceled')" class="badge badge-pill o_canceled_tag"> Cancelled </span> diff --git a/addons/sale_timesheet/views/hr_timesheet_views.xml b/addons/sale_timesheet/views/hr_timesheet_views.xml index 967ee418960..b3f34e8db5c 100644 --- a/addons/sale_timesheet/views/hr_timesheet_views.xml +++ b/addons/sale_timesheet/views/hr_timesheet_views.xml @@ -42,7 +42,7 @@ <field name="inherit_id" ref="hr_timesheet.timesheet_view_tree_user"/> <field name="arch" type="xml"> <xpath expr="//field[@name='user_id']" position="after"> - <field name="non_allow_billable" optional="hide"/> + <field name="non_allow_billable" attrs="{'invisible': [('non_allow_billable', '=', False)]}"/> </xpath> </field> </record> diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index b75a28ed748..1fcc110d8e0 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -25,8 +25,8 @@ <field name="bill_type" widget="radio"/> <field name="pricing_type" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('bill_type', '!=', 'customer_project')], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" widget="radio"/> <field name="timesheet_product_id" string="Service" attrs="{'invisible': ['|', '|', ('allow_timesheets', '=', False), ('sale_order_id', '!=', False), ('bill_type', '!=', 'customer_task')], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" context="{'default_type': 'service', 'default_service_policy': 'delivered_timesheet', 'default_service_type': 'timesheet'}"/> - <field name="sale_order_id" attrs="{'invisible': [('bill_type', '!=', 'customer_project')], 'readonly': [('sale_order_id', '!=', False)]}" force_save="1" options="{'no_create': True, 'no_edit': True, 'delete': False}"/> - <field name="sale_line_id" string="Default Sales Order Item" attrs="{'invisible': [('bill_type', '!=', 'customer_project')]}" options="{'no_create': True, 'no_edit': True, 'delete': False}"/> + <field name="sale_order_id" attrs="{'invisible': [('bill_type', '!=', 'customer_project')], 'readonly': [('sale_order_id', '!=', False)]}" force_save="1" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/> + <field name="sale_line_id" string="Default Sales Order Item" attrs="{'invisible': [('bill_type', '!=', 'customer_project')]}" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/> </group> </group> <field name="sale_line_employee_ids" attrs="{'invisible': ['|', ('bill_type', '!=', 'customer_project'), ('pricing_type', '!=', 'employee_rate')]}"> @@ -60,6 +60,21 @@ </field> </record> + <record id="project_project_view_form_salesman" model="ir.ui.view"> + <field name="name">project.project.form.inherit.salesman</field> + <field name="model">project.project</field> + <field name="inherit_id" ref="sale_timesheet.project_project_view_form"/> + <field name="groups_id" eval="[(4, ref('sales_team.group_sale_salesman'))]"/> + <field name="arch" type="xml"> + <field name="sale_order_id" position="attributes"> + <attribute name="options">{'no_create': True, 'no_edit': True, 'delete': False}</attribute> + </field> + <field name="sale_line_id" position="attributes"> + <attribute name="options">{'no_create': True, 'no_edit': True, 'delete': False}</attribute> + </field> + </field> + </record> + <record id="project_project_view_form_simplified_inherit" model="ir.ui.view"> <field name="name">project.project.view.form.simplified.inherit</field> <field name="model">project.project</field> @@ -134,10 +149,12 @@ <field name="timesheet_product_id" invisible="1"/> <field name="non_allow_billable" attrs="{'invisible': ['|', '|', '|', ('allow_billable', '=', False), ('allow_timesheets', '=', False), ('pricing_type', '!=', 'employee_rate'), ('bill_type', '=', 'customer_task')]}" invisible="1"/> </xpath> - <xpath expr="//field[@name='timesheet_ids']/tree" position="inside"> + <xpath expr="//field[@name='timesheet_ids']/tree/field[@name='unit_amount']" position="before"> <field name="timesheet_invoice_id" invisible="1"/> - <field name="so_line" readonly="1" attrs="{'column_invisible': [('parent.has_multi_sol', '=', False), '|', ('parent.is_project_map_empty', '=', True), ('parent.pricing_type', '!=', 'employee_rate')]}" optional="hide"/> - <field name="non_allow_billable" attrs="{'column_invisible': ['|', '&', '&', '|', ('parent.bill_type', '!=', 'customer_project'), ('parent.pricing_type', '!=', 'employee_rate'), ('parent.timesheet_product_id', '=', False), ('parent.sale_line_id', '=', False), '&', ('parent.bill_type', '=', 'customer_project'), ('parent.pricing_type', '=', 'employee_rate')]}" optional="hide"/> + <field name="so_line" readonly="1" attrs="{'column_invisible': [('parent.allow_billable', '=', False)]}" context="{'with_remaining_hours': True}" optional="hide"/> + </xpath> + <xpath expr="//field[@name='timesheet_ids']/tree" position="inside"> + <field name="non_allow_billable" attrs="{'column_invisible': ['|', '&', '&', '|', ('parent.bill_type', '!=', 'customer_project'), ('parent.pricing_type', '!=', 'employee_rate'), ('parent.timesheet_product_id', '=', False), ('parent.sale_line_id', '=', False), '&', ('parent.bill_type', '=', 'customer_project'), ('parent.pricing_type', '=', 'employee_rate')]}" invisible="1"/> </xpath> </field> </record> @@ -145,13 +162,31 @@ <record id="view_task_form2_inherit_sale_timesheet" model="ir.ui.view"> <field name="name">view.task.form2.inherit</field> <field name="model">project.task</field> - <field name="inherit_id" ref="sale_project.view_sale_project_inherit_form"/> + <field name="inherit_id" ref="project.view_task_form2"/> <field name="arch" type="xml"> <xpath expr="//field[@name='sale_line_id']" position="attributes"> + <attribute name="context">{'with_remaining_hours': True}</attribute> <attribute name="attrs"> - {'invisible': ['|', '|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), '&', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate'), ('partner_id', '=', False)]} + {'invisible': ['|', ('allow_billable', '=', False), ('partner_id', '=', False)]} </attribute> </xpath> + <xpath expr="//field[@name='remaining_hours']" position="after"> + <field name="remaining_hours_available" invisible="1"/> + <span id="remaining_hours_so_label" attrs="{'invisible': ['|', '|', '|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), ('partner_id', '=', False), ('sale_line_id', '=', False), ('remaining_hours_available', '=', False)]}"> + <label class="font-weight-bold" for="remaining_hours_so" string="Remaining Hours on SO" + attrs="{'invisible': ['|', ('encode_uom_in_days', '=', True), ('remaining_hours_so', '<', 0)]}"/> + <label class="font-weight-bold" for="remaining_hours_so" string="Remaining Days on SO" + attrs="{'invisible': ['|', ('encode_uom_in_days', '=', False), ('remaining_hours_so', '<', 0)]}"/> + <label class="font-weight-bold text-danger" for="remaining_hours_so" string="Remaining Hours on SO" + attrs="{'invisible': ['|', ('encode_uom_in_days', '=', True), ('remaining_hours_so', '>=', 0)]}"/> + <label class="font-weight-bold text-danger" for="remaining_hours_so" string="Remaining Days on SO" + attrs="{'invisible': ['|', ('encode_uom_in_days', '=', False), ('remaining_hours_so', '>=', 0)]}"/> + </span> + <field name="remaining_hours_so" nolabel="1" widget="timesheet_uom" attrs="{'invisible': ['|', '|', '|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), ('partner_id', '=', False), ('sale_line_id', '=', False), ('remaining_hours_available', '=', False)]}"></field> + </xpath> + <xpath expr="//field[@name='sale_order_id']" position="attributes"> + <attribute name="invisible">1</attribute> + </xpath> </field> </record> diff --git a/addons/sale_timesheet/views/report_invoice.xml b/addons/sale_timesheet/views/report_invoice.xml new file mode 100644 index 00000000000..dbf89fc2407 --- /dev/null +++ b/addons/sale_timesheet/views/report_invoice.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <template id="report_invoice_document" inherit_id="account.report_invoice_document"> + <xpath expr="//div[hasclass('page')]/h2" position="after"> + <a t-if="report_type == 'html' and o.move_type == 'out_invoice' and o.state in ('draft', 'posted') and o.timesheet_count > 0" target="_blank" t-att-href="'/my/timesheets?search_in=invoice_id&search=%s' % o.id">View Timesheets</a> + </xpath> + </template> + </data> +</odoo> diff --git a/addons/sale_timesheet/views/sale_timesheet_portal_templates.xml b/addons/sale_timesheet/views/sale_timesheet_portal_templates.xml index 9e198ababee..d983b28c095 100644 --- a/addons/sale_timesheet/views/sale_timesheet_portal_templates.xml +++ b/addons/sale_timesheet/views/sale_timesheet_portal_templates.xml @@ -7,107 +7,154 @@ </xpath> </template> + <!-- TODO: [XBO] Remove me in master --> <template id="portal_invoice_page_inherit_timesheet" inherit_id="account.portal_invoice_page"> <xpath expr="//t[@t-call='portal.portal_record_sidebar']//div[hasclass('o_download_pdf')]" position="after"> - <li t-if="timesheets" class="list-group-item flex-grow-1" > - <a href="#accordion">Timesheets</a> - </li> + <t t-if="1 == 0"> + <li t-if="timesheets" class="list-group-item flex-grow-1" > + <a href="#accordion">Timesheets</a> + </li> + </t> </xpath> <xpath expr="//div[@id='invoice_content']//div[hasclass('o_portal_html_view')]" position="after"> - <div t-if="timesheets" class="container"> - <div id="accordion" class="o_timesheet_accordion mt-4"> - <div class="card mb-0"> - <div class="card-header"> - <h5 class="mb0"> - <a class="card-title" data-toggle="collapse" href="#collapseTimesheet"> - Timesheets - </a> - </h5> - </div> - <div id="collapseTimesheet" class="card-body show" data-parent="#accordion"> - <t t-set="nr_tasks" t-value="len(timesheets.mapped('task_id'))"/> - <t t-set="nr_projects" t-value="len(timesheets.mapped('project_id'))"/> - <table class="table table-sm"> - <thead> - <tr> - <th>Date</th> - <th>Employee</th> - <th t-if="nr_projects > 1">Project</th> - <th t-if="nr_tasks > 0">Task</th> - <th>Description</th> - <th t-if="timesheets[0]._is_timesheet_encode_uom_day()" class="text-right">Duration (days)</th> - <th t-else="" class="text-right">Duration (hours)</th> - </tr> - </thead> - <tr t-foreach="timesheets" t-as="timesheet"> - <td><t t-esc="timesheet.date" t-options='{"widget": "date"}'/></td> - <td><t t-esc="timesheet.employee_id.name"/></td> - <td t-if="nr_projects > 1"><span t-field="timesheet.project_id"/></td> - <td t-if="nr_tasks > 0"><span t-field="timesheet.task_id"/></td> - <td><t t-esc="timesheet.name"/></td> - <td class="text-right"> - <span t-if="timesheet._is_timesheet_encode_uom_day()" t-esc="timesheet._get_timesheet_time_day()" t-options='{"widget": "timesheet_uom"}'/> - <span t-else="" t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/> - </td> - </tr> - </table> + <t t-if="1 == 0"> + <div t-if="timesheets" class="container"> + <div id="accordion" class="o_timesheet_accordion mt-4"> + <div class="card mb-0"> + <div class="card-header"> + <h5 class="mb0"> + <a class="card-title" data-toggle="collapse" href="#collapseTimesheet"> + Timesheets + </a> + </h5> + </div> + <div id="collapseTimesheet" class="card-body show" data-parent="#accordion"> + <t t-set="nr_tasks" t-value="len(timesheets.mapped('task_id'))"/> + <t t-set="nr_projects" t-value="len(timesheets.mapped('project_id'))"/> + <table class="table table-sm"> + <thead> + <tr> + <th>Date</th> + <th>Employee</th> + <th t-if="nr_projects > 1">Project</th> + <th t-if="nr_tasks > 0">Task</th> + <th>Description</th> + <th t-if="timesheets[0]._is_timesheet_encode_uom_day()" class="text-right">Duration (days)</th> + <th t-else="" class="text-right">Duration (hours)</th> + </tr> + </thead> + <tr t-foreach="timesheets" t-as="timesheet"> + <td><t t-esc="timesheet.date" t-options='{"widget": "date"}'/></td> + <td><t t-esc="timesheet.employee_id.name"/></td> + <td t-if="nr_projects > 1"><span t-field="timesheet.project_id"/></td> + <td t-if="nr_tasks > 0"><span t-field="timesheet.task_id"/></td> + <td><t t-esc="timesheet.name"/></td> + <td class="text-right"> + <span t-if="timesheet._is_timesheet_encode_uom_day()" t-esc="timesheet._get_timesheet_time_day()" t-options='{"widget": "timesheet_uom"}'/> + <span t-else="" t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/> + </td> + </tr> + </table> + </div> </div> </div> </div> - </div> + </t> </xpath> </template> + <template id="sale_order_portal_content_inherit" inherit_id="sale.sale_order_portal_content"> + <xpath expr="//td[@id='product_name']" position="inside"> + <a t-if="len(timesheets.filtered(lambda t: t.so_line == line)) > 0" t-att-href="'/my/timesheets?search_in=sol_id&search=%s' % line.id">View Timesheets</a> + </xpath> + </template> + + <!-- TODO: [XBO] Remove me in master --> <template id="sale_order_portal_template_inherit" inherit_id="sale.sale_order_portal_template"> <xpath expr="//t[@t-call='portal.portal_record_sidebar']//div[hasclass('o_download_pdf')]" position="after"> - <li t-if="timesheets" class="list-group-item flex-grow-1" > - <a href="#accordion">Timesheets</a> - </li> + <t t-if="1 == 0"> + <li t-if="timesheets" class="list-group-item flex-grow-1" > + <a href="#accordion">Timesheets</a> + </li> + </t> </xpath> <xpath expr="//div[@id='sale_order_communication']" position="before"> - <div t-if="timesheets" class="container"> - <div id="accordion" class="o_timesheet_accordion mt-4"> - <div class="card mb-0"> - <div class="card-header"> - <h5 class="mb0"> - <a class="card-title" data-toggle="collapse" href="#collapseTimesheet"> - Timesheets - </a> - </h5> - </div> - <div id="collapseTimesheet" class="card-body show" data-parent="#accordion"> - <t t-set="nr_tasks" t-value="len(timesheets.mapped('task_id'))"/> - <t t-set="nr_projects" t-value="len(timesheets.mapped('project_id'))"/> - <table class="table table-sm"> - <thead> - <tr> - <th>Date</th> - <th>Employee</th> - <th t-if="nr_projects > 1">Project</th> - <th t-if="nr_tasks > 0">Task</th> - <th>Description</th> - <th t-if="timesheets[0]._is_timesheet_encode_uom_day()" class="text-right">Duration (days)</th> - <th t-else="" class="text-right">Duration (hours)</th> - </tr> - </thead> - <tr t-foreach="timesheets" t-as="timesheet"> - <td><t t-esc="timesheet.date" t-options='{"widget": "date"}'/></td> - <td><t t-esc="timesheet.employee_id.name"/></td> - <td t-if="nr_projects > 1"><span t-field="timesheet.project_id"/></td> - <td t-if="nr_tasks > 0"><span t-field="timesheet.task_id"/></td> - <td><t t-esc="timesheet.name"/></td> - <td class="text-right"> - <span t-if="timesheet._is_timesheet_encode_uom_day()" t-esc="timesheet._get_timesheet_time_day()" t-options='{"widget": "timesheet_uom"}'/> - <span t-else="" t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/> - </td> - </tr> - </table> + <t t-if="1 == 0"> + <div t-if="timesheets" class="container"> + <div id="accordion" class="o_timesheet_accordion mt-4"> + <div class="card mb-0"> + <div class="card-header"> + <h5 class="mb0"> + <a class="card-title" data-toggle="collapse" href="#collapseTimesheet"> + Timesheets + </a> + </h5> + </div> + <div id="collapseTimesheet" class="card-body show" data-parent="#accordion"> + <t t-set="nr_tasks" t-value="len(timesheets.mapped('task_id'))"/> + <t t-set="nr_projects" t-value="len(timesheets.mapped('project_id'))"/> + <table class="table table-sm"> + <thead> + <tr> + <th>Date</th> + <th>Employee</th> + <th t-if="nr_projects > 1">Project</th> + <th t-if="nr_tasks > 0">Task</th> + <th>Description</th> + <th t-if="timesheets[0]._is_timesheet_encode_uom_day()" class="text-right">Duration (days)</th> + <th t-else="" class="text-right">Duration (hours)</th> + </tr> + </thead> + <tr t-foreach="timesheets" t-as="timesheet"> + <td><t t-esc="timesheet.date" t-options='{"widget": "date"}'/></td> + <td><t t-esc="timesheet.employee_id.name"/></td> + <td t-if="nr_projects > 1"><span t-field="timesheet.project_id"/></td> + <td t-if="nr_tasks > 0"><span t-field="timesheet.task_id"/></td> + <td><t t-esc="timesheet.name"/></td> + <td class="text-right"> + <span t-if="timesheet._is_timesheet_encode_uom_day()" t-esc="timesheet._get_timesheet_time_day()" t-options='{"widget": "timesheet_uom"}'/> + <span t-else="" t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/> + </td> + </tr> + </table> + </div> </div> </div> </div> - </div> + </t> + </xpath> + </template> + + <template id="portal_my_timesheets_inherit" inherit_id="hr_timesheet.portal_my_timesheets"> + <xpath expr="//thead/tr[contains(@t-attf-class, 'thead-light')]" position="inside"> + <t t-elif="groupby == 'sol'"> + <t t-set="sol" t-value="timesheets[0].so_line"/> + <th colspan="5"> + <t t-if="sol"> + <em class="font-weight-normal text-muted">Timesheets for sales order item:</em> + <span t-field="sol.display_name"/> + <t t-if="sol.remaining_hours_available"> + <span class="text-muted font-weight-normal">(<span t-field="sol.product_uom_qty" t-options='{"widget": "float_time"}'></span> <span t-field="sol.product_uom.display_name"></span> Ordered, <span t-field="sol.remaining_hours" t-options='{"widget": "float_time"}'></span> <span t-field="sol.product_uom.display_name"></span> Remaining)</span> + </t> + </t> + </th> + <th colspan="1" class="text-right text-muted font-weight-normal"> + <t t-if="is_uom_day"> + Total: <span t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/> + </t> + <t t-else=""> + Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/> + </t> + </th> + </t> + </xpath> + <xpath expr="//thead/tr/th[@t-if='is_uom_day']" position="before"> + <th t-if="not groupby == 'sol'">Sale Order Item</th> + </xpath> + <xpath expr="//tbody//td[hasclass('text-right')]" position="before"> + <td t-if="not groupby == 'sol'"><span t-field="timesheet.so_line" t-att-title="timesheet.so_line.display_name"></span></td> </xpath> </template> diff --git a/addons/sale_timesheet_edit/__init__.py b/addons/sale_timesheet_edit/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/sale_timesheet_edit/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/sale_timesheet_edit/__manifest__.py b/addons/sale_timesheet_edit/__manifest__.py new file mode 100644 index 00000000000..6178dbe0135 --- /dev/null +++ b/addons/sale_timesheet_edit/__manifest__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# TODO: [XBO] merge with sale_timesheet module in master +{ + 'name': 'Sales Timesheet Edit', + 'category': 'Hidden', + 'summary': 'Edit the sale order line linked in the timesheets', + 'description': """ +Allow to edit sale order line in the timesheets +=============================================== + +This module adds the edition of the sale order line +set in the timesheets. This allows adds more flexibility +to the user to easily change the sale order line on a +timesheet in task form view when it is needed. +""", + 'depends': ['sale_timesheet'], + 'data': [ + 'views/assets.xml', + 'views/project_task.xml', + ], + 'demo': [], + 'auto_install': True, +} diff --git a/addons/sale_timesheet_edit/models/__init__.py b/addons/sale_timesheet_edit/models/__init__.py new file mode 100644 index 00000000000..bf8826f4bc0 --- /dev/null +++ b/addons/sale_timesheet_edit/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_analytic_line +from . import project diff --git a/addons/sale_timesheet_edit/models/account_analytic_line.py b/addons/sale_timesheet_edit/models/account_analytic_line.py new file mode 100644 index 00000000000..5ff797ddc34 --- /dev/null +++ b/addons/sale_timesheet_edit/models/account_analytic_line.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +# TODO: [XBO] merge with account.analytic.line in the sale_timesheet module in master. +class AccountAnalyticLine(models.Model): + _inherit = 'account.analytic.line' + + is_so_line_edited = fields.Boolean() + + @api.depends('task_id.sale_line_id', 'project_id.sale_line_id', 'project_id.allow_billable', 'employee_id') + def _compute_so_line(self): + super(AccountAnalyticLine, self.filtered(lambda t: not t.is_so_line_edited))._compute_so_line() + + def _check_sale_line_in_project_map(self): + # TODO: [XBO] remove me in master, now we authorize to manually edit the so_line, then this so_line can be different of the one in task/project/map_entry + # !!! Override of the method in sale_timesheet !!! + return diff --git a/addons/sale_timesheet_edit/models/project.py b/addons/sale_timesheet_edit/models/project.py new file mode 100644 index 00000000000..b10bafddbcf --- /dev/null +++ b/addons/sale_timesheet_edit/models/project.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class Project(models.Model): + _inherit = 'project.project' + + def _get_not_billed_timesheets(self): + """ Get the timesheets not invoiced and the SOL has not manually been edited + FIXME: [XBO] this change must be done in the _update_timesheets_sale_line_id + rather than this method in master to keep the initial behaviour of this method. + """ + return super(Project, self)._get_not_billed_timesheets() - self.mapped('timesheet_ids').filtered('is_so_line_edited') diff --git a/addons/sale_timesheet_edit/static/src/js/so_line_one2many.js b/addons/sale_timesheet_edit/static/src/js/so_line_one2many.js new file mode 100644 index 00000000000..bc638b04b34 --- /dev/null +++ b/addons/sale_timesheet_edit/static/src/js/so_line_one2many.js @@ -0,0 +1,28 @@ +odoo.define('sale_timesheet_edit.so_line_many2one', function (require) { +"use strict"; + +const fieldRegistry = require('web.field_registry'); +const FieldOne2Many = require('web.relational_fields').FieldOne2Many; + +const SoLineOne2Many = FieldOne2Many.extend({ + _onFieldChanged: function (ev) { + if ( + ev.data.changes && + ev.data.changes.hasOwnProperty('timesheet_ids') && + ev.data.changes.timesheet_ids.operation === 'UPDATE' && + ev.data.changes.timesheet_ids.data.hasOwnProperty('so_line')) { + const line = this.value.data.find(line => { + return line.id === ev.data.changes.timesheet_ids.id; + }); + if (!line.is_so_line_edited) { + ev.data.changes.timesheet_ids.data.is_so_line_edited = true; + } + } + this._super.apply(this, arguments); + } +}); + + +fieldRegistry.add('so_line_one2many', SoLineOne2Many); + +}); diff --git a/addons/sale_timesheet_edit/views/assets.xml b/addons/sale_timesheet_edit/views/assets.xml new file mode 100644 index 00000000000..b8909b44964 --- /dev/null +++ b/addons/sale_timesheet_edit/views/assets.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + + <template id="assets_backend" inherit_id="web.assets_backend"> + <xpath expr="script[last()]" position="after"> + <script type="text/javascript" src="/sale_timesheet_edit/static/src/js/so_line_one2many.js"></script> + </xpath> + </template> + +</odoo> diff --git a/addons/sale_timesheet_edit/views/project_task.xml b/addons/sale_timesheet_edit/views/project_task.xml new file mode 100644 index 00000000000..f9f7e38e3ee --- /dev/null +++ b/addons/sale_timesheet_edit/views/project_task.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + + <!-- This view can be removed to change only if it is needed in the inherit view in sale_timesheet --> + <record id="project_task_view_form_inherit_sale_timesheet_edit" model="ir.ui.view"> + <field name="name">project.task.form.view.form.inherit.sale.timesheet.edit</field> + <field name="model">project.task</field> + <field name="inherit_id" ref="sale_timesheet.project_task_view_form_inherit_sale_timesheet"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='timesheet_ids']" position="attributes"> + <attribute name="widget">so_line_one2many</attribute> + </xpath> + <xpath expr="//field[@name='timesheet_ids']/tree/field[@name='so_line']" position="attributes"> + <attribute name="readonly">0</attribute> + <attribute name="domain">[('is_service', '=', True), ('order_partner_id', 'child_of', parent.commercial_partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('order_id', '=?', parent.project_sale_order_id)]</attribute> + <attribute name="options">{'no_create': True, 'no_open': True}</attribute> + </xpath> + </field> + </record> + + <!-- + TODO: [XBO] In master, add this view in the sale_timesheet when we will merge of the both modules + Don't forget to change the inherit_id to have the correct view in sale_timesheet, + since the view above can be merged with project_task_view_form_inherit_sale_timesheet view. + --> + <record id="project_task_view_form_inherit_sale_timesheet_editable" model="ir.ui.view"> + <field name="name">project.task.form.view.form.inherit.sale.timesheet.editable</field> + <field name="model">project.task</field> + <field name="inherit_id" ref="project_task_view_form_inherit_sale_timesheet_edit"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='timesheet_ids']/tree/field[@name='so_line']" position="attributes"> + <attribute name="options">{'no_create': True}</attribute> + </xpath> + <xpath expr="//field[@name='timesheet_ids']/tree" position="inside"> + <field name="is_so_line_edited" invisible="1" /> + </xpath> + </field> + <field name="groups_id" eval="[(4, ref('sales_team.group_sale_salesman'))]"/> + </record> + +</odoo> |
