feat(portal): add timesheets portal (list + edit) and clean assets

Portal Timesheets Feature\n- Add new portal timesheets list with edit modal: views/portal_timesheets_templates.xml\n- New controller: controllers/timesheets_portal.py\n- New model for event timesheets: models/sports_event_timesheet.py\n- Security: sports_event_timesheet_rules.xml + ir.model.access.csv updates\n\nEvent/Portal Integration\n- Wire timesheets into events portal: controllers/events_portal.py, views/portal_event_* templates, menus\n- Sports event model enhancements: models/sports_event.py\n\nGeneral Portal/View Updates\n- Updates across player/team/user/partner views for consistency and UX\n\nAsset Cleanup\n- Remove unused 24h datetime initializer from frontend bundles\n  * Dropped bemade_sports_clinic/static/src/js/portal_datetime_24h.js from __manifest__.py assets\n\nNotes\n- No functional dependency on the 24h initializer remains; inputs use native datetime-local or explicit formatting in templates.
This commit is contained in:
Denis Durepos 2025-09-22 21:33:57 -04:00
parent dffb2755db
commit cbd9f2faab
25 changed files with 1442 additions and 193 deletions

View file

@ -50,6 +50,7 @@ This module provides a complete sports medicine clinic management solution with
"phone_validation", # For phone number formatting in patient contacts
"project", # Required for project.task (Events) functionality
"account", # Ensure account portal templates (e.g., portal_my_home_invoice) are available
"hr_timesheet", # Ensure our portal card override loads after core timesheet portal
],
"external_dependencies": {
"python": [
@ -67,6 +68,7 @@ This module provides a complete sports medicine clinic management solution with
"security/project_task_portal_rules.xml",
"security/sports_event_rules.xml",
"security/partner_access.xml",
"security/sports_event_timesheet_rules.xml",
"data/sports_clinic_data.xml",
"data/admin_access_data.xml",
# "data/project_portal_demo_data.xml", # Temporarily disabled for clean upgrade
@ -90,6 +92,7 @@ This module provides a complete sports medicine clinic management solution with
"views/portal_event_detail_template.xml",
"views/portal_event_edit_template.xml",
"views/portal_event_create_template.xml",
"views/portal_timesheets_templates.xml",
"views/treatment_note_views.xml",
"views/res_partner_views.xml",
"views/task_to_event_wizard_views.xml",

View file

@ -5,3 +5,4 @@ from . import team_management_portal
from . import player_management_portal
from . import task_management_portal
from . import events_portal
from . import timesheets_portal

View file

@ -6,6 +6,7 @@ from datetime import datetime, timedelta
from .access_control_mixin import AccessControlMixin
import logging
import pytz
import urllib.parse
_logger = logging.getLogger(__name__)
@ -391,14 +392,128 @@ class EventsPortal(CustomerPortal, AccessControlMixin):
# Check if user can edit (only therapists)
can_edit = is_therapist
# Helper to format dt for datetime-local inputs in user's tz
def _format_dt_local(dt):
if not dt:
return ''
try:
tz_name = (http.request.context.get('tz') if http.request and http.request.context else None) or \
(http.request.env.user.tz if http.request else None) or 'UTC'
user_tz = pytz.timezone(tz_name)
except Exception:
user_tz = pytz.UTC
# Odoo stores as UTC-naive; localize to UTC first
if dt.tzinfo is None:
utc_dt = pytz.UTC.localize(dt)
else:
utc_dt = dt.astimezone(pytz.UTC)
local_dt = utc_dt.astimezone(user_tz)
return local_dt.strftime('%Y-%m-%dT%H:%M')
# Timesheet context for current user
my_ts = http.request.env['sports.event.timesheet'].search([
('event_id', '=', event.id),
('user_id', '=', user.id),
], limit=1)
has_my_timesheet = bool(my_ts)
# Missing timesheets info
missing_users = event._get_missing_timesheet_user_ids() if hasattr(event, '_get_missing_timesheet_user_ids') else http.request.env['res.users']
missing_count = len(missing_users)
missing_names = ', '.join(missing_users.mapped('name')) if missing_users else ''
values = {
'event': event,
'page_name': 'event_detail',
'can_edit': can_edit,
# Timesheet UI context
'has_my_timesheet': has_my_timesheet,
'my_timesheet': my_ts,
# Default local times for modal fields (fallback to therapist/event range)
'ts_travel_start_local': _format_dt_local(my_ts.travel_start if my_ts else (event.therapist_start or event.date_start)),
'ts_coverage_start_local': _format_dt_local(my_ts.coverage_start if my_ts else (event.therapist_start or event.date_start)),
'ts_coverage_end_local': _format_dt_local(my_ts.coverage_end if my_ts else (event.therapist_end or event.date_end)),
'ts_travel_end_local': _format_dt_local(my_ts.travel_end if my_ts else (event.therapist_end or event.date_end)),
# Completion warnings
'missing_count': missing_count,
'missing_names': missing_names,
}
return http.request.render('bemade_sports_clinic.portal_event_detail', values)
@http.route(['/my/event/<int:event_id>/timesheet/add'], type='http', auth='user', website=True, methods=['POST'], csrf=False)
def add_timesheet(self, event_id, **post):
"""Create or update the current user's timesheet for the event"""
user = http.request.env.user
# Access: therapists only for adding timesheets (or system)
is_therapist = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or \
user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional')
if not (is_therapist or user.has_group('base.group_system')):
raise AccessError(_("You don't have permission to add timesheets."))
event = http.request.env['sports.event'].browse(event_id)
if not event.exists():
return http.request.not_found()
# Build values
vals = {
'event_id': event.id,
'user_id': user.id,
}
for key, field_name in [('travel_start', 'travel_start'),
('coverage_start', 'coverage_start'),
('coverage_end', 'coverage_end'),
('travel_end', 'travel_end')]:
if post.get(key):
vals[field_name] = self._parse_portal_datetime(post.get(key))
# Create or update existing timesheet for this user
ts_model = http.request.env['sports.event.timesheet']
existing = ts_model.search([('event_id', '=', event.id), ('user_id', '=', user.id)], limit=1)
try:
if existing:
existing.write(vals)
ts_id = existing.id
else:
ts = ts_model.create(vals)
ts_id = ts.id
return http.request.redirect(f'/my/event/{event.id}?ts_saved=1')
except Exception as e:
msg = str(e).replace('\n', ' ').replace('\r', ' ')
return http.request.redirect(f'/my/event/{event.id}?ts_error={msg}')
@http.route(['/my/event/<int:event_id>/mark_complete'], type='http', auth='user', website=True, methods=['POST'], csrf=False)
def mark_event_complete(self, event_id, **post):
"""Mark an event completed with non-blocking warning if timesheets missing"""
user = http.request.env.user
is_therapist = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or \
user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional')
if not (is_therapist or user.has_group('base.group_system')):
raise AccessError(_("You don't have permission to complete events."))
event = http.request.env['sports.event'].browse(event_id)
if not event.exists():
return http.request.not_found()
force = post.get('force')
missing_users = event._get_missing_timesheet_user_ids() if hasattr(event, '_get_missing_timesheet_user_ids') else http.request.env['res.users']
if missing_users and not force:
names = ', '.join(missing_users.mapped('name'))
# Non-blocking: redirect back with warning
return http.request.redirect(f"/my/event/{event.id}?warn_missing=1&missing={urllib.parse.quote(names)}")
# Perform completion (sudo not needed if model allows write; internal only in model guard but here we allow portal therapists via controller action)
try:
event.write({'state': 'completed'})
try:
event.message_post(body=_('Event marked Completed via portal'))
except Exception:
pass
return http.request.redirect(f'/my/event/{event.id}?completed=1')
except Exception as e:
msg = str(e).replace('\n', ' ').replace('\r', ' ')
return http.request.redirect(f'/my/event/{event.id}?error={msg}')
@http.route(['/my/event/<int:event_id>/edit'], type='http', auth='user', website=True)
def edit_event_form(self, event_id, **kw):
"""Edit event form - only accessible to therapists"""
@ -489,8 +604,9 @@ class EventsPortal(CustomerPortal, AccessControlMixin):
update_vals['venue_id'] = int(post['venue_id'])
if 'event_type' in post:
update_vals['event_type'] = post['event_type']
if 'state' in post:
update_vals['state'] = post['state']
# NOTE: Workflow is internal-only for now. Do not accept 'state' from portal.
# TODO(bemade_sports_clinic): Implement therapist portal actions to change state
# (e.g., mark in progress, mark completed) with proper access checks.
if 'date_start' in post and post['date_start']:
update_vals['date_start'] = self._parse_portal_datetime(post['date_start'])
@ -598,8 +714,9 @@ class EventsPortal(CustomerPortal, AccessControlMixin):
create_vals['venue_id'] = int(post['venue_id'])
if 'event_type' in post:
create_vals['event_type'] = post['event_type']
if 'state' in post:
create_vals['state'] = post['state']
# NOTE: Workflow is internal-only for now. Do not accept 'state' from portal.
# TODO(bemade_sports_clinic): Implement therapist portal actions to change state
# (e.g., mark in progress, mark completed) with proper access checks.
# Datetime fields
def _parse_dt(val):

View file

@ -596,13 +596,13 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin):
team_id_list = [int(tid) for tid in request.httprequest.form.getlist('team_ids')]
except Exception:
team_id_list = []
if team_id_list:
# Defense-in-depth: restrict to teams where current user is staff
allowed_team_ids = request.env['sports.team.staff'].search([
('partner_id', '=', request.env.user.partner_id.id)
]).mapped('team_id').ids
filtered_team_ids = [tid for tid in team_id_list if tid in allowed_team_ids]
vals['team_ids'] = [(6, 0, list(set(filtered_team_ids)))]
# Defense-in-depth: restrict to teams where current user is staff
allowed_team_ids = request.env['sports.team.staff'].search([
('partner_id', '=', request.env.user.partner_id.id)
]).mapped('team_id').ids
filtered_team_ids = [tid for tid in team_id_list if tid in allowed_team_ids]
# Always set the M2M command, even if empty, to allow clearing all teams
vals['team_ids'] = [(6, 0, list(set(filtered_team_ids)))]
if post.get('date_of_birth'):
vals.update({
'date_of_birth': post.get('date_of_birth'),

View file

@ -17,6 +17,10 @@ class TeamStaffPortal(CustomerPortal):
activities_domain)
rtn['events_count'] = http.request.env['sports.event'].search_count(
events_domain)
# Timesheets count (therapists only)
user = http.request.env.user
if user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system'):
rtn['timesheets_count'] = http.request.env['sports.event.timesheet'].search_count([('user_id', '=', user.id)])
return rtn
@classmethod

View file

@ -0,0 +1,220 @@
from odoo.addons.portal.controllers.portal import CustomerPortal, pager
from odoo import http, _, fields
from odoo.exceptions import AccessError, UserError
from datetime import datetime
import pytz
import logging
_logger = logging.getLogger(__name__)
class TimesheetsPortal(CustomerPortal):
def _parse_portal_datetime(self, val):
"""Parse a datetime-local input (YYYY-MM-DDTHH:MM[:SS]) from the portal
as user-local time and convert to UTC string suitable for fields.Datetime.
Returns False if empty.
"""
if not val:
return False
dt = None
try:
if 'T' in val:
try:
dt = datetime.strptime(val, '%Y-%m-%dT%H:%M')
except ValueError:
dt = datetime.strptime(val, '%Y-%m-%dT%H:%M:%S')
else:
try:
dt = datetime.strptime(val, '%Y-%m-%d %H:%M')
except ValueError:
dt = datetime.strptime(val, '%Y-%m-%d %H:%M:%S')
except ValueError:
# Fallback: let ORM try
return val
tz_name = (http.request.context.get('tz') if http.request and http.request.context else None) or \
(http.request.env.user.tz if http.request else None) or 'UTC'
try:
user_tz = pytz.timezone(tz_name)
except Exception:
user_tz = pytz.UTC
local_dt = user_tz.localize(dt)
utc_dt = local_dt.astimezone(pytz.UTC)
return fields.Datetime.to_string(utc_dt)
def _prepare_home_portal_values(self, counters):
vals = super()._prepare_home_portal_values(counters)
if 'event_timesheets_count' in counters:
user = http.request.env.user
if user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system'):
# Count timesheets owned by the current user OR by an internal user sharing the same partner
count_domain = ['|', ('user_id', '=', user.id), ('user_id.partner_id', '=', user.partner_id.id)]
count = http.request.env['sports.event.timesheet'].search_count(count_domain)
vals['event_timesheets_count'] = count
_logger.debug(
"[TimesheetsPortal] Home counter (event_timesheets_count) user=%s(partner=%s) domain=%s -> count=%s",
user.id, user.partner_id.id, count_domain, count,
)
return vals
def _prepare_timesheets_domain(self, user_only=True):
user = http.request.env.user
if not (user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system')):
# No access for non-therapists in portal
return [('id', '=', 0)]
domain = []
if user_only:
# Show records owned by the exact user or by an internal user sharing the same partner
domain.extend(['|', ('user_id', '=', user.id), ('user_id.partner_id', '=', user.partner_id.id)])
_logger.debug(
"[TimesheetsPortal] _prepare_timesheets_domain user=%s(partner=%s) user_only=%s -> %s",
user.id, user.partner_id.id, user_only, domain,
)
return domain
@http.route(['/my/sc/timesheets', '/my/sc/timesheets/page/<int:page>'], type='http', auth='user', website=True)
def view_timesheets(self, page=1, date_from=None, date_to=None, team_id=None, organization_id=None, group_by=None, sortby=None, search=None, **kw):
_logger.warning("[TimesheetsPortal] ENTER /my/sc/timesheets page=%s", page)
user = http.request.env.user
if not (user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system')):
raise AccessError(_("You don't have access to timesheets."))
domain = self._prepare_timesheets_domain(user_only=True)
# Filters
_logger.debug(
"[TimesheetsPortal] Incoming filters user=%s(partner=%s) date_from=%s date_to=%s team_id=%s org_id=%s group_by=%s sortby=%s search=%s",
user.id, user.partner_id.id, date_from, date_to, team_id, organization_id, group_by, sortby, search,
)
if date_from:
try:
dt = fields.Datetime.from_string(date_from)
domain.append(('coverage_start', '>=', dt))
except Exception:
pass
if date_to:
try:
dt = fields.Datetime.from_string(date_to)
domain.append(('coverage_start', '<=', dt))
except Exception:
pass
if team_id:
try:
domain.append(('event_id.team_id', '=', int(team_id)))
except Exception:
pass
if organization_id:
try:
domain.append(('event_id.partner_id', '=', int(organization_id)))
except Exception:
pass
if search:
domain.extend(['|', '|',
('event_id.name', 'ilike', search),
('event_id.team_id.name', 'ilike', search),
('event_id.partner_id.name', 'ilike', search)])
# Sorting
sort_options = {
'date': 'coverage_start asc',
'date_desc': 'coverage_start desc',
'team': 'event_id.team_id',
'org': 'event_id.partner_id',
'state': 'state',
}
order = sort_options.get(sortby, 'coverage_start asc')
Timesheet = http.request.env['sports.event.timesheet']
_logger.debug("[TimesheetsPortal] Final domain=%s order=%s", domain, order)
total = Timesheet.search_count(domain)
_logger.debug("[TimesheetsPortal] search_count=%s", total)
pgr = pager(url='/my/sc/timesheets', total=total, page=page, step=self._items_per_page,
url_args={'date_from': date_from, 'date_to': date_to, 'team_id': team_id, 'organization_id': organization_id,
'group_by': group_by, 'sortby': sortby, 'search': search})
timesheets = Timesheet.search(domain, order=order, limit=self._items_per_page, offset=pgr['offset'])
_logger.debug("[TimesheetsPortal] search result ids=%s", timesheets.ids)
# Filters data sources
teams = http.request.env['sports.team'].search([])
orgs = teams.mapped('parent_id').filtered(lambda p: p).sorted('name')
_logger.debug(
"[TimesheetsPortal] UI sources: teams=%s orgs=%s",
len(teams), len(orgs),
)
# Grouping structure
grouped = None
if group_by in ('date', 'organization', 'team'):
from collections import OrderedDict
grouped = OrderedDict()
for ts in timesheets:
if group_by == 'date':
key = (fields.Date.to_string(fields.Datetime.context_timestamp(user, ts.coverage_start).date()) if ts.coverage_start else 'No Date')
label = key
elif group_by == 'organization':
key = ts.event_id.partner_id.id or 0
label = ts.event_id.partner_id.name or _('No Organization')
else: # team
key = ts.event_id.team_id.id or 0
label = ts.event_id.team_id.name or _('No Team')
if key not in grouped:
grouped[key] = {'label': label, 'items': []}
grouped[key]['items'].append(ts)
grouped = list(grouped.values())
# Build user-local datetime strings for datetime-local inputs in the edit modal
# Format: '%Y-%m-%dT%H:%M' expected by HTML input type=datetime-local
local_dt_map = {}
for ts in timesheets:
def _fmt(dt):
if not dt:
return ''
try:
return fields.Datetime.context_timestamp(user, dt).strftime('%Y-%m-%dT%H:%M')
except Exception:
# Fallback to raw string
return fields.Datetime.to_string(dt)[:16].replace(' ', 'T')
local_dt_map[ts.id] = {
'travel_start': _fmt(ts.travel_start),
'coverage_start': _fmt(ts.coverage_start),
'coverage_end': _fmt(ts.coverage_end),
'travel_end': _fmt(ts.travel_end),
}
values = {
'page_name': 'timesheets',
'timesheets': timesheets,
'pager': pgr,
'default_url': '/my/sc/timesheets',
'date_from': date_from,
'date_to': date_to,
'team_id': int(team_id) if team_id else None,
'organization_id': int(organization_id) if organization_id else None,
'group_by': group_by,
'sortby': sortby,
'search': search,
'teams': teams,
'organizations': orgs,
'grouped': grouped,
# Mapping of timesheet.id -> localized datetime strings for edit modal fields
'timesheet_local_dt': local_dt_map,
}
return http.request.render('bemade_sports_clinic.portal_timesheets_list', values)
@http.route(['/my/sc/timesheet/<int:ts_id>/edit'], type='http', auth='user', website=True, methods=['POST'], csrf=False)
def edit_timesheet(self, ts_id, **post):
user = http.request.env.user
if not (user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system')):
raise AccessError(_("You don't have permission to edit timesheets."))
ts = http.request.env['sports.event.timesheet'].browse(ts_id)
if not ts.exists() or ts.user_id.id != user.id:
raise AccessError(_("You can only edit your own timesheets."))
if ts.state == 'invoiced':
raise UserError(_('This timesheet is invoiced and cannot be edited.'))
vals = {}
for k in ['travel_start', 'coverage_start', 'coverage_end', 'travel_end']:
if post.get(k):
vals[k] = self._parse_portal_datetime(post.get(k))
if vals:
ts.write(vals)
return http.request.redirect('/my/sc/timesheets?updated=1')

View file

@ -13,3 +13,4 @@ from . import project_project
from . import sports_event
from . import base_partner_merge
from . import team_role_mass_assign_wizard
from . import sports_event_timesheet

View file

@ -6,7 +6,7 @@ class SportsEvent(models.Model):
_name = 'sports.event'
_description = 'Sports Event'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'date_start desc, name'
_order = 'date_start asc, name'
_rec_name = 'name'
# Portal access group definition - only authorized portal users
@ -90,6 +90,7 @@ class SportsEvent(models.Model):
('confirmed', 'Confirmed'),
('in_progress', 'In Progress'),
('completed', 'Completed'),
('invoiced', 'Invoiced'),
('cancelled', 'Cancelled')
], string='Status', default='confirmed', tracking=True, groups=_portal_groups)
@ -116,6 +117,20 @@ class SportsEvent(models.Model):
groups=_portal_groups,
help='Treatment professionals assigned to this event'
)
# Timesheets: one per assigned therapist
timesheet_ids = fields.One2many(
'sports.event.timesheet', 'event_id',
string='Timesheets',
help='Timesheets logged by assigned therapists for this event'
)
timesheet_count = fields.Integer(
string='Timesheet Count',
compute='_compute_timesheet_count',
store=False,
help='Number of timesheets recorded for this event'
)
# ========================================
# TASK INTEGRATION (Internal Management)
@ -176,6 +191,15 @@ class SportsEvent(models.Model):
compute='_compute_is_upcoming',
help='Whether the event is in the future'
)
# Helper field used in views to filter staff pickers to treatment professionals only
treatment_professional_user_ids = fields.Many2many(
'res.users',
string='Treatment Professional Users',
compute='_compute_treatment_professional_user_ids',
store=False,
help='All users who are treatment professionals (internal and portal). Used to filter staff selection.'
)
# ========================================
# COMPUTED METHODS
@ -241,6 +265,29 @@ class SportsEvent(models.Model):
event.is_upcoming = event.date_start > now
else:
event.is_upcoming = False
def _compute_treatment_professional_user_ids(self):
"""Compute the list of users who are treatment professionals.
Includes both internal treatment professionals and portal treatment professionals.
"""
# Resolve groups safely via env.ref
tp_internal = self.env.ref('bemade_sports_clinic.group_sports_clinic_treatment_professional', raise_if_not_found=False)
tp_portal = self.env.ref('bemade_sports_clinic.group_portal_treatment_professional', raise_if_not_found=False)
group_ids = [g.id for g in (tp_internal, tp_portal) if g]
users = self.env['res.users']
if group_ids:
users = users.search([('active', '=', True), ('groups_id', 'in', group_ids)])
else:
users = users.browse()
for event in self:
event.treatment_professional_user_ids = users
def _compute_timesheet_count(self):
for event in self:
event.timesheet_count = len(event.timesheet_ids)
# ========================================
# ONCHANGE METHODS
@ -249,13 +296,13 @@ class SportsEvent(models.Model):
@api.onchange('date_start')
def _onchange_date_start(self):
"""When event start changes:
- therapist_start = 120 minutes prior
- therapist_start = same as date_start
- date_end = 2 hours after
- therapist_end = date_end
"""
if self.date_start:
from datetime import timedelta
self.therapist_start = self.date_start - timedelta(minutes=120)
self.therapist_start = self.date_start
self.date_end = self.date_start + timedelta(hours=2)
self.therapist_end = self.date_end
@ -419,3 +466,72 @@ class SportsEvent(models.Model):
task_vals['date_end'] = self.therapist_end
self.task_id.write(task_vals)
# ========================================
# INTERNAL WORKFLOW ACTIONS
# ========================================
def action_mark_in_progress(self):
"""Mark event as In Progress (internal users only)"""
internal_user = self.env.user.has_group('base.group_user')
if not internal_user:
# Safety guard: internal-only
raise ValidationError("Only internal users can change event workflow state.")
for event in self:
if event.state in ('draft', 'confirmed'):
event.write({'state': 'in_progress'})
try:
event.message_post(body="Event marked In Progress")
except Exception:
pass
return True
def action_mark_completed(self):
"""Mark event as Completed (internal users only)"""
internal_user = self.env.user.has_group('base.group_user')
if not internal_user:
raise ValidationError("Only internal users can change event workflow state.")
for event in self:
if event.state in ('in_progress', 'confirmed', 'draft'):
# Non-blocking warning if not all assigned therapists have timesheets
missing_users = event._get_missing_timesheet_user_ids()
if missing_users:
# Notify but do not block
try:
names = ', '.join(missing_users.mapped('name'))
event.message_post(body=f"Warning: Completing event without timesheets for: {names}")
except Exception:
pass
event.write({'state': 'completed'})
try:
event.message_post(body="Event marked Completed")
except Exception:
pass
return True
# ========================================
# HELPERS
# ========================================
def _get_missing_timesheet_user_ids(self):
"""Return res.users records for assigned staff who do not have a timesheet yet"""
self.ensure_one()
assigned = self.assigned_staff_ids
if not assigned:
return self.env['res.users']
have_ts_users = self.timesheet_ids.mapped('user_id')
missing = assigned - have_ts_users
return missing
def action_mark_invoiced(self):
"""Mark event as Invoiced (internal users only). Typically done after billing."""
internal_user = self.env.user.has_group('base.group_user')
if not internal_user:
raise ValidationError("Only internal users can change event workflow state.")
for event in self:
# Allow invoiced from completed or cancelled (if billed anyway), and idempotent
if event.state in ('completed', 'cancelled', 'invoiced'):
event.write({'state': 'invoiced'})
try:
event.message_post(body="Event marked Invoiced")
except Exception:
pass
return True

View file

@ -0,0 +1,186 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class SportsEventTimesheet(models.Model):
_name = 'sports.event.timesheet'
_description = 'Sports Event Timesheet'
_order = 'coverage_start asc'
event_id = fields.Many2one(
'sports.event',
string='Event',
required=True,
ondelete='cascade',
index=True,
help='Event this timesheet belongs to'
)
user_id = fields.Many2one(
'res.users',
string='Therapist',
required=True,
index=True,
help='Therapist entering this timesheet'
)
# State
state = fields.Selection(
[
('submitted', 'Submitted'),
('invoiced', 'Invoiced'),
],
string='Status',
default='submitted',
index=True,
help='Submitted: editable; Invoiced: read-only'
)
# Times
travel_start = fields.Datetime(string='Travel Start')
coverage_start = fields.Datetime(string='Coverage Start')
coverage_end = fields.Datetime(string='Coverage End')
travel_end = fields.Datetime(string='Travel End')
# Computed durations
coverage_duration = fields.Float(
string='Coverage Duration (Hours)',
compute='_compute_durations',
store=True,
help='Hours of coverage (coverage_end - coverage_start)'
)
travel_duration = fields.Float(
string='Travel Duration (Hours)',
compute='_compute_durations',
store=True,
help='Total travel time before and after coverage'
)
_sql_constraints = [
('event_user_unique', 'unique(event_id, user_id)', 'Each therapist may only have one timesheet per event.'),
]
# ------------------------------------------------------
# DEFAULTS + ONCHANGES (so values show before write)
# ------------------------------------------------------
@api.model
def default_get(self, fields_list):
"""Prefill times based on event when opening a fresh record (form or inline)."""
res = super().default_get(fields_list)
event_id = self.env.context.get('default_event_id')
event = self.env['sports.event'].browse(event_id) if event_id else self.env['sports.event']
cov_start = event and (event.therapist_start or event.date_start) or False
cov_end = event and (event.therapist_end or event.date_end) or False
if 'event_id' in fields_list and not res.get('event_id') and event_id:
res['event_id'] = event_id
if 'user_id' in fields_list and not res.get('user_id'):
res['user_id'] = self.env.user.id
if 'state' in fields_list and not res.get('state'):
res['state'] = 'submitted'
if 'coverage_start' in fields_list and not res.get('coverage_start'):
res['coverage_start'] = cov_start
if 'coverage_end' in fields_list and not res.get('coverage_end'):
res['coverage_end'] = cov_end
if 'travel_start' in fields_list and not res.get('travel_start'):
res['travel_start'] = res.get('coverage_start') or cov_start
if 'travel_end' in fields_list and not res.get('travel_end'):
res['travel_end'] = res.get('coverage_end') or cov_end
return res
@api.onchange('event_id')
def _onchange_event_id(self):
"""When changing event, prefill times if empty so the user sees defaults immediately."""
event = self.event_id
if not event:
return
cov_start = event.therapist_start or event.date_start
cov_end = event.therapist_end or event.date_end
if not self.coverage_start:
self.coverage_start = cov_start
if not self.coverage_end:
self.coverage_end = cov_end
if not self.travel_start:
self.travel_start = self.coverage_start or cov_start
if not self.travel_end:
self.travel_end = self.coverage_end or cov_end
@api.onchange('coverage_start')
def _onchange_coverage_start(self):
"""If travel_start is not set, align it to coverage_start on change."""
if self.coverage_start and not self.travel_start:
self.travel_start = self.coverage_start
@api.onchange('coverage_end')
def _onchange_coverage_end(self):
"""If travel_end is not set, align it to coverage_end on change."""
if self.coverage_end and not self.travel_end:
self.travel_end = self.coverage_end
@api.model_create_multi
def create(self, vals_list):
# Default times from the event if not provided
for vals in vals_list:
event = None
if vals.get('event_id'):
event = self.env['sports.event'].browse(vals['event_id'])
# Default coverage to therapist times
if event:
if not vals.get('coverage_start'):
vals['coverage_start'] = event.therapist_start or event.date_start
if not vals.get('coverage_end'):
vals['coverage_end'] = event.therapist_end or event.date_end
# Default travel to coverage if not provided
if not vals.get('travel_start'):
vals['travel_start'] = vals.get('coverage_start')
if not vals.get('travel_end'):
vals['travel_end'] = vals.get('coverage_end')
# Ensure default state
vals.setdefault('state', 'submitted')
return super().create(vals_list)
def write(self, vals):
# Prevent editing invoiced records (read-only)
if any(rec.state == 'invoiced' for rec in self):
# Allow no-op state write (e.g., writing the same state) and block changes to other fields
blocked_fields = set(vals.keys()) - {'state'}
if blocked_fields or (vals.get('state') and any(rec.state == 'invoiced' and vals.get('state') != 'invoiced' for rec in self)):
raise ValidationError('Timesheets are read-only once invoiced.')
return super().write(vals)
def unlink(self):
if any(rec.state == 'invoiced' for rec in self):
raise ValidationError('Invoiced timesheets cannot be deleted.')
return super().unlink()
@api.depends('coverage_start', 'coverage_end', 'travel_start', 'travel_end')
def _compute_durations(self):
for ts in self:
# Coverage duration
if ts.coverage_start and ts.coverage_end and ts.coverage_end > ts.coverage_start:
cov = (ts.coverage_end - ts.coverage_start).total_seconds() / 3600.0
else:
cov = 0.0
# Travel before and after coverage
before = 0.0
after = 0.0
if ts.travel_start and ts.coverage_start and ts.coverage_start > ts.travel_start:
before = (ts.coverage_start - ts.travel_start).total_seconds() / 3600.0
if ts.travel_end and ts.coverage_end and ts.travel_end > ts.coverage_end:
after = (ts.travel_end - ts.coverage_end).total_seconds() / 3600.0
ts.coverage_duration = cov
ts.travel_duration = max(0.0, before) + max(0.0, after)
@api.constrains('coverage_start', 'coverage_end', 'travel_start', 'travel_end')
def _check_times(self):
for ts in self:
# Basic ordering constraints
if ts.coverage_start and ts.coverage_end and ts.coverage_end <= ts.coverage_start:
raise ValidationError('Coverage end must be after coverage start.')
if ts.travel_start and ts.coverage_start and ts.travel_start > ts.coverage_start:
raise ValidationError('Travel start must be on or before coverage start.')
if ts.travel_end and ts.coverage_end and ts.travel_end < ts.coverage_end:
raise ValidationError('Travel end must be on or after coverage end.')

View file

@ -70,3 +70,6 @@ access_team_role_mass_assign_wizard_admin,Admin Access for Team Role Mass Assign
access_team_role_mass_assign_wizard_admin2,Clinic Admin Access for Team Role Mass Assign Wizard,model_team_role_mass_assign_wizard,bemade_sports_clinic.group_sports_clinic_admin,1,1,1,1
access_team_role_mass_assign_line_admin,Admin Access for Team Role Mass Assign Line,model_team_role_mass_assign_line,base.group_system,1,1,1,1
access_team_role_mass_assign_line_admin2,Clinic Admin Access for Team Role Mass Assign Line,model_team_role_mass_assign_line,bemade_sports_clinic.group_sports_clinic_admin,1,1,1,1
access_event_timesheet_user,User Access for Event Timesheets,model_sports_event_timesheet,base.group_user,1,1,1,1
access_event_timesheet_portal_tp,Portal TP Access for Event Timesheets,model_sports_event_timesheet,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
70 access_team_role_mass_assign_line_admin Admin Access for Team Role Mass Assign Line model_team_role_mass_assign_line base.group_system 1 1 1 1
71 access_team_role_mass_assign_line_admin2 Clinic Admin Access for Team Role Mass Assign Line model_team_role_mass_assign_line bemade_sports_clinic.group_sports_clinic_admin 1 1 1 1
72 access_event_timesheet_user User Access for Event Timesheets model_sports_event_timesheet base.group_user 1 1 1 1
73 access_event_timesheet_portal_tp Portal TP Access for Event Timesheets model_sports_event_timesheet bemade_sports_clinic.group_portal_treatment_professional 1 1 1 0
74
75

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<!-- Record rules for sports.event.timesheet -->
<!-- Internal users: full access to all timesheets -->
<record id="rule_event_timesheet_user" model="ir.rule">
<field name="name">Event Timesheet: Internal Users</field>
<field name="model_id" ref="model_sports_event_timesheet"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Portal Treatment Professionals: read ALL timesheets -->
<record id="rule_event_timesheet_portal_tp_readall" model="ir.rule">
<field name="name">Event Timesheet: Portal TP Read All</field>
<field name="model_id" ref="model_sports_event_timesheet"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Portal Treatment Professionals: write own timesheets only -->
<record id="rule_event_timesheet_portal_tp_write_own" model="ir.rule">
<field name="name">Event Timesheet: Portal TP Write Own</field>
<field name="model_id" ref="model_sports_event_timesheet"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Portal Treatment Professionals: create own timesheets only -->
<record id="rule_event_timesheet_portal_tp_create_own" model="ir.rule">
<field name="name">Event Timesheet: Portal TP Create Own</field>
<field name="model_id" ref="model_sports_event_timesheet"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>

View file

@ -344,7 +344,7 @@
<span class="text-muted"></span>
<span t-esc="event.therapist_start" t-options="{'widget': 'datetime', 'format': 'HH:mm'}"/>
<t t-if="event.date_start">
<small class="text-muted"> (sched <span t-esc="event.date_start" t-options="{'widget': 'datetime', 'format': 'HH:mm'}"/>)</small>
</t>
</t>
<t t-if="not event.therapist_start and event.date_start">

View file

@ -24,6 +24,44 @@
</ol>
</nav>
</div>
<!-- Add Timesheet Modal -->
<t t-if="can_edit">
<div class="modal fade" id="addTimesheetModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add My Timesheet</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form t-attf-action="/my/event/#{event.id}/timesheet/add" method="post">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Travel Start</label>
<input type="datetime-local" class="form-control" name="travel_start" t-att-value="ts_travel_start_local"/>
</div>
<div class="mb-3">
<label class="form-label">Coverage Start</label>
<input type="datetime-local" class="form-control" name="coverage_start" t-att-value="ts_coverage_start_local"/>
</div>
<div class="mb-3">
<label class="form-label">Coverage End</label>
<input type="datetime-local" class="form-control" name="coverage_end" t-att-value="ts_coverage_end_local"/>
</div>
<div class="mb-3">
<label class="form-label">Travel End</label>
<input type="datetime-local" class="form-control" name="travel_end" t-att-value="ts_travel_end_local"/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Add Timesheet</button>
</div>
</form>
</div>
</div>
</div>
</t>
</div>
<!-- Header -->
@ -39,6 +77,16 @@
<a t-attf-href="/my/event/#{event.id}/edit" class="btn btn-primary">
<i class="fa fa-edit"/> Edit Event
</a>
<!-- Add My Timesheet button -->
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#addTimesheetModal">
<i class="fa fa-clock-o"/> Add My Timesheet
</button>
<!-- Mark Complete button -->
<form t-attf-action="/my/event/#{event.id}/mark_complete" method="post" class="d-inline">
<button type="submit" class="btn btn-warning">
<i class="fa fa-check"/> Mark Complete
</button>
</form>
</t>
</div>
</div>
@ -52,18 +100,62 @@
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
</t>
<t t-if="request.params.get('completed')">
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fa fa-check-circle"/> Event marked completed.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
</t>
<t t-if="request.params.get('ts_saved')">
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fa fa-check-circle"/> Timesheet saved.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
</t>
<t t-if="request.params.get('ts_error')">
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fa fa-exclamation-circle"/> Timesheet error: <span t-esc="request.params.get('ts_error')"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
</t>
<t t-if="request.params.get('warn_missing')">
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<i class="fa fa-exclamation-triangle"/> Some assigned therapists have not added timesheets:
<strong><t t-esc="request.params.get('missing') or missing_names"/></strong>
<div class="mt-2">
<form t-attf-action="/my/event/#{event.id}/mark_complete" method="post" class="d-inline">
<input type="hidden" name="force" value="1"/>
<button type="submit" class="btn btn-sm btn-warning">
<i class="fa fa-check"/> Complete Anyway
</button>
</form>
<a t-attf-href="/my/event/#{event.id}" class="btn btn-sm btn-outline-secondary ms-2">Cancel</a>
</div>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
</t>
<!-- Event Details -->
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa fa-info-circle"/> Event Details
</h5>
<ul class="nav nav-tabs card-header-tabs" id="eventDetailTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-overview-tab" data-bs-toggle="tab" data-bs-target="#tab-overview" type="button" role="tab" aria-controls="tab-overview" aria-selected="true">
<i class="fa fa-info-circle"/> Overview
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-timesheets-tab" data-bs-toggle="tab" data-bs-target="#tab-timesheets" type="button" role="tab" aria-controls="tab-timesheets" aria-selected="false">
<i class="fa fa-clock-o"/> Timesheets
</button>
</li>
</ul>
</div>
<div class="card-body">
<div class="row">
<div class="card-body tab-content" id="eventDetailTabsContent">
<div class="tab-pane fade show active" id="tab-overview" role="tabpanel" aria-labelledby="tab-overview-tab">
<div class="row">
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-4">Type:</dt>
@ -148,6 +240,43 @@
<h6>Description:</h6>
<div t-field="event.description" class="oe_no_empty"/>
</t>
</div>
<div class="tab-pane fade" id="tab-timesheets" role="tabpanel" aria-labelledby="tab-timesheets-tab">
<t t-if="event.timesheet_ids">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Therapist</th>
<th class="text-nowrap">Travel Start</th>
<th class="text-nowrap">Coverage Start</th>
<th class="text-nowrap">Coverage End</th>
<th class="text-nowrap">Travel End</th>
<th class="text-end">Coverage (h)</th>
<th class="text-end">Travel (h)</th>
</tr>
</thead>
<tbody>
<t t-foreach="event.timesheet_ids" t-as="ts">
<tr>
<td><span t-esc="ts.user_id.name"/></td>
<td class="text-nowrap"><span t-esc="ts.travel_start" t-options="{'widget': 'datetime'}"/></td>
<td class="text-nowrap"><span t-esc="ts.coverage_start" t-options="{'widget': 'datetime'}"/></td>
<td class="text-nowrap"><span t-esc="ts.coverage_end" t-options="{'widget': 'datetime'}"/></td>
<td class="text-nowrap"><span t-esc="ts.travel_end" t-options="{'widget': 'datetime'}"/></td>
<td class="text-end"><span t-esc="ts.coverage_duration" t-options="{'widget': 'float_time'}"/></td>
<td class="text-end"><span t-esc="ts.travel_duration" t-options="{'widget': 'float_time'}"/></td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
<t t-if="not event.timesheet_ids">
<p class="text-muted mb-0">No timesheets yet.</p>
</t>
</div>
</div>
</div>
</div>

View file

@ -91,16 +91,21 @@
</select>
</div>
<!-- Status -->
<!-- Status (read-only in portal for now) -->
<!-- TODO(bemade_sports_clinic): Portal therapist workflow
Add portal actions for therapists to transition status (e.g., Mark In Progress, Mark Completed).
Keep state immutable via portal edit form until workflows are implemented with proper access checks.
-->
<div class="mb-3">
<label for="state" class="form-label">Status</label>
<select class="form-select" id="state" name="state">
<option value="draft" t-att-selected="event.state == 'draft'">Draft</option>
<option value="confirmed" t-att-selected="event.state == 'confirmed'">Confirmed</option>
<option value="in_progress" t-att-selected="event.state == 'in_progress'">In Progress</option>
<option value="completed" t-att-selected="event.state == 'completed'">Completed</option>
<option value="cancelled" t-att-selected="event.state == 'cancelled'">Cancelled</option>
</select>
<label class="form-label">Status</label>
<div>
<span class="badge bg-secondary" t-if="event.state == 'draft'">Draft</span>
<span class="badge bg-info" t-if="event.state == 'confirmed'">Confirmed</span>
<span class="badge bg-primary" t-if="event.state == 'in_progress'">In Progress</span>
<span class="badge bg-success" t-if="event.state == 'completed'">Completed</span>
<span class="badge bg-warning text-dark" t-if="event.state == 'invoiced'">Invoiced</span>
<span class="badge bg-danger" t-if="event.state == 'cancelled'">Cancelled</span>
</div>
</div>
</div>

View file

@ -0,0 +1,273 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<!-- Portal Timesheets List (cards) -->
<template id="portal_timesheets_list" name="Portal Timesheets List">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<t t-call="portal.portal_searchbar">
<t t-set="title">Timesheets</t>
</t>
<div class="container-fluid">
<!-- Breadcrumbs -->
<div class="row">
<div class="col-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/my">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">Timesheets</li>
</ol>
</nav>
</div>
</div>
<!-- Filters -->
<div class="row mb-3">
<div class="col-12">
<form method="get" class="o_portal_search_panel" action="/my/sc/timesheets">
<div class="row g-2 mb-2">
<!-- Organization Filter -->
<div class="col-md-4">
<select name="organization_id" class="form-select" onchange="this.form.submit();">
<option value="">All Organizations</option>
<t t-foreach="organizations" t-as="org">
<option t-att-value="org.id" t-att-selected="'selected' if organization_id and organization_id == org.id else None">
<t t-esc="org.name"/>
</option>
</t>
</select>
</div>
<!-- Team Filter -->
<div class="col-md-4">
<select name="team_id" class="form-select" onchange="this.form.submit();">
<option value="">All Teams</option>
<t t-foreach="teams" t-as="team">
<option t-att-value="team.id" t-att-selected="team_id == team.id">
<t t-esc="team.name"/>
</option>
</t>
</select>
</div>
<!-- Free Text Search -->
<div class="col-md-4">
<div class="input-group">
<input type="text" name="search" class="form-control" placeholder="Search (event / team / organization)" t-att-value="search or ''"/>
<button type="submit" class="btn btn-outline-secondary"><i class="fa fa-search"/></button>
</div>
</div>
</div>
<div class="row g-2">
<!-- Date from/to -->
<div class="col-md-6">
<label class="form-label text-muted small">From Date</label>
<input type="date" name="date_from" class="form-control" t-att-value="date_from or ''" onchange="this.form.submit();"/>
</div>
<div class="col-md-6">
<label class="form-label text-muted small">To Date</label>
<input type="date" name="date_to" class="form-control" t-att-value="date_to or ''" onchange="this.form.submit();"/>
</div>
</div>
</form>
</div>
</div>
<!-- Count + Sort/Group -->
<div class="row mb-3">
<div class="col-12 d-flex justify-content-between align-items-center">
<div class="text-muted">
<t t-if="timesheets">
Showing <strong t-esc="len(timesheets)"/> timesheets
<t t-if="pager and pager.get('page_count', 1) > 1">
(Page <t t-esc="pager.get('page', {}).get('num', 1)"/> of <t t-esc="pager.get('page_count', 1)"/>)
</t>
</t>
<t t-if="not timesheets">
<strong>0</strong> timesheets found
</t>
</div>
<form method="get" action="/my/sc/timesheets" class="d-flex align-items-center gap-2">
<input type="hidden" name="team_id" t-att-value="team_id or ''"/>
<input type="hidden" name="organization_id" t-att-value="organization_id or ''"/>
<input type="hidden" name="date_from" t-att-value="date_from or ''"/>
<input type="hidden" name="date_to" t-att-value="date_to or ''"/>
<input type="hidden" name="search" t-att-value="search or ''"/>
<label class="small text-muted">Sort by</label>
<select name="sortby" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="date" t-att-selected="'selected' if (not sortby or sortby == 'date') else None">Date (asc)</option>
<option value="date_desc" t-att-selected="'selected' if sortby == 'date_desc' else None">Date (desc)</option>
<option value="team" t-att-selected="'selected' if sortby == 'team' else None">Team</option>
<option value="org" t-att-selected="'selected' if sortby == 'org' else None">Organization</option>
<option value="state" t-att-selected="'selected' if sortby == 'state' else None">Status</option>
</select>
<label class="small text-muted">Group by</label>
<select name="group_by" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="" t-att-selected="'selected' if not group_by else None">None</option>
<option value="date" t-att-selected="'selected' if group_by == 'date' else None">Date</option>
<option value="organization" t-att-selected="'selected' if group_by == 'organization' else None">Organization</option>
<option value="team" t-att-selected="'selected' if group_by == 'team' else None">Team</option>
</select>
</form>
</div>
</div>
<!-- Grouped view -->
<t t-if="timesheets and group_by and grouped">
<div class="accordion" id="tsAccordion">
<t t-foreach="grouped" t-as="grp">
<div class="accordion-item mb-2">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" t-attf-data-bs-target="#grp-{{ (grp.get('label') or '').replace(' ', '-') }}" aria-expanded="false">
<span class="fw-semibold" t-esc="grp.get('label')"/>
<span class="ms-2 badge bg-light text-muted" t-esc="len(grp.get('items') or [])"/>
</button>
</h2>
<div class="accordion-collapse collapse" t-attf-id="grp-{{ (grp.get('label') or '').replace(' ', '-') }}" data-bs-parent="#tsAccordion">
<div class="accordion-body">
<div class="row g-2">
<t t-foreach="grp.get('items')" t-as="ts">
<t t-call="bemade_sports_clinic._timesheet_card"/>
</t>
</div>
</div>
</div>
</div>
</t>
</div>
<t t-call="portal.pager"/>
</t>
<!-- Flat grid view -->
<t t-if="timesheets and not group_by">
<div class="row g-2">
<t t-foreach="timesheets" t-as="ts">
<t t-call="bemade_sports_clinic._timesheet_card"/>
</t>
</div>
<t t-call="portal.pager"/>
</t>
<t t-if="not timesheets">
<div class="alert alert-info text-center">
<h4>No timesheets found</h4>
<p>There are no timesheets matching your criteria.</p>
</div>
</t>
</div>
</t>
</template>
<!-- Reusable Timesheet Card -->
<template id="_timesheet_card">
<div class="col-12">
<div class="card h-100 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold text-break" t-esc="ts.event_id.name or 'Event'"/>
<div class="text-muted small">
<i class="fa fa-calendar me-1"/>
<t t-if="ts.coverage_start">
<span t-esc="ts.coverage_start" t-options="{'widget': 'date', 'format': 'yyyy-MM-dd'}"/>
<span class="text-muted"></span>
<span t-esc="ts.coverage_start" t-options="{'widget': 'datetime', 'format': 'HH:mm'}"/>
</t>
<t t-else="">
<span class="text-muted">No date</span>
</t>
</div>
<div class="text-muted small mt-1">
<i class="fa fa-users me-1"/>
<t t-if="ts.event_id.team_id"><span t-esc="ts.event_id.team_id.name"/></t>
<t t-else=""><span class="text-muted">No Team</span></t>
<span class="ms-2">
<i class="fa fa-sitemap me-1"/>
<t t-if="ts.event_id.partner_id"><span t-esc="ts.event_id.partner_id.name"/></t>
<t t-else=""><span class="text-muted">No Organization</span></t>
</span>
</div>
</div>
<div class="text-end">
<span class="badge bg-secondary me-2" t-esc="dict(ts._fields['state'].selection).get(ts.state, ts.state)"/>
<div class="mt-2">
<button t-if="ts.state != 'invoiced'" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" t-attf-data-bs-target="#editTs{{ ts.id }}">
<i class="fa fa-pencil"/> Edit
</button>
<button t-if="ts.state == 'invoiced'" class="btn btn-sm btn-outline-secondary" disabled="disabled" title="Invoiced (read-only)">
<i class="fa fa-lock"/> Locked
</button>
</div>
</div>
</div>
<div class="row mt-3 g-2">
<div class="col-6">
<div class="small text-muted"><i class="fa fa-car me-1"/> Travel Start</div>
<div><span t-esc="ts.travel_start" t-options="{'widget': 'datetime', 'format': 'yyyy-MM-dd HH:mm'}"/></div>
</div>
<div class="col-6">
<div class="small text-muted"><i class="fa fa-clock-o me-1"/> Coverage Start</div>
<div><span t-esc="ts.coverage_start" t-options="{'widget': 'datetime', 'format': 'yyyy-MM-dd HH:mm'}"/></div>
</div>
<div class="col-6">
<div class="small text-muted"><i class="fa fa-flag-checkered me-1"/> Travel End</div>
<div><span t-esc="ts.travel_end" t-options="{'widget': 'datetime', 'format': 'yyyy-MM-dd HH:mm'}"/></div>
</div>
<div class="col-6">
<div class="small text-muted"><i class="fa fa-clock-o me-1"/> Coverage End</div>
<div><span t-esc="ts.coverage_end" t-options="{'widget': 'datetime', 'format': 'yyyy-MM-dd HH:mm'}"/></div>
</div>
<div class="col-6">
<div class="small text-muted"><i class="fa fa-road me-1"/> Travel Hours</div>
<div><span t-esc="ts.travel_duration" t-options="{'widget': 'float_time'}"/></div>
</div>
<div class="col-6">
<div class="small text-muted"><i class="fa fa-hourglass-half me-1"/> Coverage Hours</div>
<div><span t-esc="ts.coverage_duration" t-options="{'widget': 'float_time'}"/></div>
</div>
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<div t-attf-id="editTs{{ ts.id }}" class="modal fade" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form t-attf-action="/my/sc/timesheet/{{ ts.id }}/edit" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="modal-header">
<h5 class="modal-title">Edit Timesheet</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2">
<div class="col-12">
<label class="form-label">Travel Start</label>
<input class="form-control" type="datetime-local" name="travel_start" t-att-value="(timesheet_local_dt.get(ts.id) or {}).get('travel_start') or ''"/>
</div>
<div class="col-12">
<label class="form-label">Coverage Start</label>
<input class="form-control" type="datetime-local" name="coverage_start" t-att-value="(timesheet_local_dt.get(ts.id) or {}).get('coverage_start') or ''"/>
</div>
<div class="col-12">
<label class="form-label">Coverage End</label>
<input class="form-control" type="datetime-local" name="coverage_end" t-att-value="(timesheet_local_dt.get(ts.id) or {}).get('coverage_end') or ''"/>
</div>
<div class="col-12">
<label class="form-label">Travel End</label>
<input class="form-control" type="datetime-local" name="travel_end" t-att-value="(timesheet_local_dt.get(ts.id) or {}).get('travel_end') or ''"/>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn bg-o-color-1 text-white"><i class="fa fa-save"/> Save</button>
</div>
</form>
</div>
</div>
</div>
</template>
</data>
</odoo>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="res_partner_view_form_sports_teams" model="ir.ui.view">
<field name="name">res.partner.view.form.sports.teams</field>
<field name="inherit_id" ref="base.view_partner_form"/>
@ -31,4 +32,6 @@
</page>
</field>
</record>
</data>
</odoo>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<data>
<!-- Add Sports Clinic security groups to the user form access rights section -->
<record id="view_users_form_inherit" model="ir.ui.view">
<field name="name">view.users.form.inherit</field>
@ -48,10 +49,10 @@
<field name="partner_id" invisible="1"/>
<field name="share" invisible="1"/>
</group>
<field name="partner_id" widget="res_partner_many2one" readonly="1" options="{'always_reload': True}"/>
</page>
</page>
</field>
</record>
</data>
</odoo>

View file

@ -36,23 +36,49 @@
<field name="res_model">sports.patient</field>
<field name="view_mode">list,kanban,form</field>
</record>
<!-- Timesheets action -->
<record id="action_view_timesheets" model="ir.actions.act_window">
<field name="name">Event Timesheets</field>
<field name="res_model">sports.event.timesheet</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="sports_clinic_root"
web_icon="bemade_sports_clinic,static/description/icon.png"
name="Sports Clinic"
groups="bemade_sports_clinic.group_sports_clinic_user"
action="action_view_team">
<menuitem id="sports_clinic_organizations"
name="Organizations"
groups="bemade_sports_clinic.group_sports_clinic_user"
action="action_view_organization"/>
<menuitem id="sports_clinic_teams"
name="Teams"
groups="bemade_sports_clinic.group_sports_clinic_user"
action="action_view_team"/>
<menuitem id="sports_clinic_patients"
name="Patients"
groups="bemade_sports_clinic.group_sports_clinic_user"
action="action_view_patient"/>
</menuitem>
action="action_view_team"/>
<menuitem id="sports_clinic_organizations"
name="Organizations"
groups="bemade_sports_clinic.group_sports_clinic_user"
action="action_view_organization"/>
<menuitem id="sports_clinic_teams"
name="Teams"
groups="bemade_sports_clinic.group_sports_clinic_user"
action="action_view_team"/>
<menuitem id="sports_clinic_patients"
name="Patients"
groups="bemade_sports_clinic.group_sports_clinic_user"
action="action_view_patient"/>
<!-- Staff submenu (admin visibility) -->
<menuitem id="sports_clinic_staff_menu"
name="Staff"
parent="sports_clinic_root"
groups="bemade_sports_clinic.group_sports_clinic_admin"
sequence="80"/>
<menuitem id="sports_clinic_staff_timesheets"
name="Timesheets"
parent="sports_clinic_staff_menu"
groups="bemade_sports_clinic.group_sports_clinic_admin"
action="action_view_timesheets"
sequence="10"/>
<menuitem id="sports_clinic_staff_users"
name="Users"
parent="sports_clinic_staff_menu"
groups="bemade_sports_clinic.group_sports_clinic_admin"
action="base.action_res_users"
sequence="20"/>
</data>
</odoo>

View file

@ -27,10 +27,29 @@
<t t-set="placeholder_count">events_count</t>
<t t-set="config_card" t-value="True"/>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Event Timesheets</t>
<t t-set="url" t-value="'/my/sc/timesheets'"/>
<t t-set="placeholder_count">event_timesheets_count</t>
<t t-set="config_card" t-value="True"/>
</t>
</t>
</div>
</xpath>
</template>
<!-- Ensure the core hr_timesheet home card points to our custom route and counter -->
<template id="portal_my_home_timesheet_override_url" inherit_id="hr_timesheet.portal_my_home_timesheet" priority="200">
<xpath expr="//t[@t-set='title']" position="replace">
<t t-set="title">Event Timesheets</t>
</xpath>
<xpath expr="//t[@t-set='url']" position="replace">
<t t-set="url" t-value="'/my/sc/timesheets'"/>
</xpath>
<xpath expr="//t[@t-set='placeholder_count']" position="replace">
<t t-set="placeholder_count" t-value="'event_timesheets_count'"/>
</xpath>
</template>
<!-- Reusable patient card snippet used across Players and Team views -->
<template id="portal_patient_card">
<t t-set="stage" t-value="player.stage"/>

View file

@ -7,7 +7,7 @@
<field name="name">sports.event.list</field>
<field name="model">sports.event</field>
<field name="arch" type="xml">
<list string="Sports Events" default_order="date_start desc">
<list string="Sports Events" default_order="date_start asc">
<field name="name"/>
<field name="description" widget="html"/>
<field name="event_type"/>
@ -20,10 +20,59 @@
<field name="therapist_end" widget="datetime"/>
<field name="therapist_duration" widget="float_time"/>
<field name="venue_id" context="{'default_is_venue': True}"/>
<field name="assigned_staff_ids" widget="many2many_tags"/>
<field name="assigned_staff_ids" widget="many2many_tags"
domain="[('id', 'in', treatment_professional_user_ids)]"/>
</list>
</field>
</record>
<!-- Subviews for sports.event.timesheet -->
<record id="sports_event_timesheet_tree" model="ir.ui.view">
<field name="name">sports.event.timesheet.list</field>
<field name="model">sports.event.timesheet</field>
<field name="arch" type="xml">
<list string="Event Timesheets" editable="bottom">
<field name="user_id" readonly="state == 'invoiced'"/>
<field name="travel_start" readonly="state == 'invoiced'"/>
<field name="coverage_start" readonly="state == 'invoiced'"/>
<field name="coverage_end" readonly="state == 'invoiced'"/>
<field name="travel_end" readonly="state == 'invoiced'"/>
<field name="coverage_duration" widget="float_time"/>
<field name="travel_duration" widget="float_time"/>
<field name="state"/>
</list>
</field>
</record>
<record id="sports_event_timesheet_form" model="ir.ui.view">
<field name="name">sports.event.timesheet.form</field>
<field name="model">sports.event.timesheet</field>
<field name="arch" type="xml">
<form string="Event Timesheet">
<group>
<field name="user_id" readonly="state == 'invoiced'"/>
<field name="travel_start" readonly="state == 'invoiced'"/>
<field name="coverage_start" readonly="state == 'invoiced'"/>
<field name="coverage_end" readonly="state == 'invoiced'"/>
<field name="travel_end" readonly="state == 'invoiced'"/>
<field name="state"/>
<separator string="Computed"/>
<field name="coverage_duration" widget="float_time" readonly="1"/>
<field name="travel_duration" widget="float_time" readonly="1"/>
</group>
</form>
</field>
</record>
<!-- Timesheets action filtered by current event -->
<record id="sports_event_timesheet_action" model="ir.actions.act_window">
<field name="name">Event Timesheets</field>
<field name="res_model">sports.event.timesheet</field>
<field name="view_mode">list,form</field>
<field name="domain">[("event_id", "=", active_id)]</field>
<field name="context">{"default_event_id": active_id}</field>
<field name="target">current</field>
</record>
<!-- Sports Event Form View -->
<record id="sports_event_view_form" model="ir.ui.view">
@ -38,8 +87,21 @@
invisible="task_id != False"
groups="base.group_user"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,confirmed,in_progress,completed"
statusbar_visible="draft,confirmed,in_progress,completed,invoiced"
clickable="1"/>
<!-- Internal-only workflow buttons -->
<button name="action_mark_in_progress" type="object"
string="Mark In Progress" class="btn-secondary"
invisible="(state in ['in_progress','completed','invoiced','cancelled']) or (id == False)"
groups="base.group_user"/>
<button name="action_mark_completed" type="object"
string="Mark Completed" class="btn-success"
invisible="(state in ['completed','invoiced','cancelled']) or (id == False)"
groups="base.group_user"/>
<button name="action_mark_invoiced" type="object"
string="Mark Invoiced" class="btn-warning"
invisible="(state not in ['completed','cancelled','invoiced']) or (id == False)"
groups="base.group_user"/>
</header>
<sheet>
@ -48,6 +110,11 @@
<field name="name" placeholder="Event Name"/>
</h1>
</div>
<div class="oe_button_box" name="button_box">
<button type="action" name="%(sports_event_timesheet_action)d" class="oe_stat_button" icon="fa-clock-o" invisible="not id">
<field name="timesheet_count" widget="statinfo" string="Timesheets"/>
</button>
</div>
<group>
<group string="Event Details">
@ -83,7 +150,8 @@
<page string="Staff Assignment">
<group>
<label for="assigned_staff_ids" string="Assigned Staff"/>
<field name="assigned_staff_ids" widget="many2many_checkboxes" nolabel="1">
<field name="assigned_staff_ids" widget="many2many_checkboxes" nolabel="1"
domain="[('id', 'in', treatment_professional_user_ids)]">
<list>
<field name="name"/>
<field name="email"/>
@ -93,6 +161,14 @@
</group>
</page>
<page string="Timesheets" groups="base.group_user">
<group>
<field name="timesheet_ids" context="{'default_event_id': id}" nolabel="1"
views="[(ref('bemade_sports_clinic.sports_event_timesheet_tree'), 'list'),
(ref('bemade_sports_clinic.sports_event_timesheet_form'), 'form')]"/>
</group>
</page>
<page string="Task Integration" groups="base.group_user">
<group>
<field name="task_id" readonly="1"/>
@ -141,6 +217,8 @@
domain="[('state', '=', 'in_progress')]"/>
<filter name="completed" string="Completed"
domain="[('state', '=', 'completed')]"/>
<filter name="invoiced" string="Invoiced"
domain="[('state', '=', 'invoiced')]"/>
<separator/>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<!-- Injury Document: backend views -->
<record id="view_injury_document_tree" model="ir.ui.view">
<field name="name">sports.injury.document.list</field>
@ -191,4 +192,5 @@
</list>
</field>
</record>
</data>
</odoo>

View file

@ -233,6 +233,5 @@
</form>
</field>
</record>
>
</data>
</odoo>

View file

@ -1,156 +1,164 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="teams_view_search" model="ir.ui.view">
<field name="name">teams.view.search</field>
<field name="model">sports.team</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="parent_id" string="Parent Organization"/>
<field name="head_coach_id"/>
<field name="head_therapist_id"/>
<filter context="{'group_by': 'parent_id'}" name="groupby_parent_id"/>
<filter context="{'group_by': 'head_coach_id'}" name="groupby_head_coach_id"/>
<filter context="{'group_by': 'head_therapist_id'}" name="groupby_head_therapist_id"/>
</search></field>
</record>
<record id="sports_team_view_kanban" model="ir.ui.view">
<field name="name">sports.team.view.kanban</field>
<field name="model">sports.team</field>
<field name="arch" type="xml">
<kanban archivable="false" default_group_by="parent_id" default_order="name" group_create="false" group_delete="false">
<field name="name"/>
<field name="parent_id"/>
<field name="head_coach_name"/>
<field name="head_therapist_name"/>
<field name="injured_count"/>
<field name="healthy_count"/>
<templates>
<t t-name="card">
<div t-attf-class="oe_kanban_content oe_kanban_global_click">
<div>
<strong class="o_kanban_record_title">
<data>
<record id="teams_view_search" model="ir.ui.view">
<field name="name">teams.view.search</field>
<field name="model">sports.team</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="parent_id" string="Parent Organization"/>
<field name="head_coach_id"/>
<field name="head_therapist_id"/>
<filter context="{'group_by': 'parent_id'}" name="groupby_parent_id"/>
<filter context="{'group_by': 'head_coach_id'}" name="groupby_head_coach_id"/>
<filter context="{'group_by': 'head_therapist_id'}" name="groupby_head_therapist_id"/>
</search>
</field>
</record>
<record id="sports_team_view_kanban" model="ir.ui.view">
<field name="name">sports.team.view.kanban</field>
<field name="model">sports.team</field>
<field name="arch" type="xml">
<kanban archivable="false" default_group_by="parent_id" default_order="name" group_create="false" group_delete="false">
<field name="name"/>
<field name="parent_id"/>
<field name="head_coach_name"/>
<field name="head_therapist_name"/>
<field name="injured_count"/>
<field name="healthy_count"/>
<templates>
<t t-name="card">
<div t-attf-class="oe_kanban_content oe_kanban_global_click">
<div>
<strong class="o_kanban_record_title">
<span>
<field name="name"/>
</span>
</strong>
</div>
<div>
<span class="o_kanban_record_subtitle">
<field name="parent_id"/>
</span>
</div>
<div>
<span>
<field name="name"/>
</span>
</strong>
</div>
<div>
<span class="o_kanban_record_subtitle">
<field name="parent_id"/>
</span>
</div>
<div>
<span>
Head coach:
<field name="head_coach_name"/>
</span>
</div>
<div>
<span>
Head therapist:
<field name="head_therapist_name"/>
</span>
</div>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left">
<span class="text-success">
Healthy:
<field name="healthy_count"/>
Head coach:
<field name="head_coach_name"/>
</span>
</div>
<div class="oe_kanban_bottom_right">
<span t-attf-class="{{record.injured_count.value != 0 ? 'text-danger' : ''}}">
Injured:
<field name="injured_count"/>
<div>
<span>
Head therapist:
<field name="head_therapist_name"/>
</span>
</div>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left">
<span class="text-success">
Healthy:
<field name="healthy_count"/>
</span>
</div>
<div class="oe_kanban_bottom_right">
<span t-attf-class="{{record.injured_count.value != 0 ? 'text-danger' : ''}}">
Injured:
<field name="injured_count"/>
</span>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="sports_team_view_list" model="ir.ui.view">
<field name="name">sports.team.view.list</field>
<field name="model">sports.team</field>
<field name="arch" type="xml">
<list string="Sports Teams">
<field name="name"/>
<field name="parent_id"/>
<field name="head_coach_id"/>
<field name="head_therapist_id"/>
<field name="player_count"/>
<field name="injured_count"/>
</list>
</field>
</record>
<record id="sports_team_view_form" model="ir.ui.view">
<field name="name">sports.team.view.form</field>
<field name="model">sports.team</field>
<field name="arch" type="xml">
<form>
<field invisible="1" name="id"/>
<header/>
<sheet>
<div class="oe_button_box"/>
<div class="oe_title">
<h1>
<field name="name"/>
</h1>
</div>
</t>
</templates>
</kanban></field>
</record>
<record id="sports_team_view_list" model="ir.ui.view">
<field name="name">sports.team.view.list</field>
<field name="model">sports.team</field>
<field name="arch" type="xml">
<list string="Sports Teams">
<field name="name"/>
<field name="parent_id"/>
<field name="head_coach_id"/>
<field name="head_therapist_id"/>
<field name="player_count"/>
<field name="injured_count"/>
</list></field>
</record>
<record id="sports_team_view_form" model="ir.ui.view">
<field name="name">sports.team.view.form</field>
<field name="model">sports.team</field>
<field name="arch" type="xml">
<form>
<field invisible="1" name="id"/>
<header/>
<sheet>
<div class="oe_button_box"/>
<div class="oe_title">
<h1>
<field name="name"/>
</h1>
</div>
<group string="Team Information">
<group>
<field name="parent_id"/>
<field name="player_count"/>
<field name="injured_count"/>
<group string="Team Information">
<group>
<field name="parent_id"/>
<field name="player_count"/>
<field name="injured_count"/>
</group>
<group>
<field name="staff_ids">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="role"/>
<field name="mobile" widget="phone"/>
<field column_invisible="True" name="has_portal_access"/>
<field column_invisible="True" name="partner_id"/>
<button class="oe_highlight" groups="bemade_sports_clinic.group_sports_clinic_admin,base.group_system" icon="fa-user-plus" invisible="has_portal_access == True" name="action_grant_portal_access" title="Grant Portal Access" type="object"/>
<button class="oe_highlight" groups="bemade_sports_clinic.group_sports_clinic_admin,base.group_system" icon="fa-ban" invisible="has_portal_access == False" name="action_revoke_portal_access" title="Revoke Portal Access" type="object"/>
</list>
</field>
</group>
</group>
<notebook>
<page name="players" string="Players">
<field name="patient_ids" nolabel="1"
context="{'list_view_ref': 'bemade_sports_clinic.sports_patient_view_list_embedded'}"/>
</page>
<page name="user_access"
string="User Access"
groups="bemade_sports_clinic.group_sports_clinic_admin,base.group_system">
<field name="allowed_user_ids"/>
</page>
</notebook>
</sheet>
<chatter reload_on_post="True"/>
</form>
</field>
</record>
<record id="sports_team_staff_view_form" model="ir.ui.view">
<field name="name">sports.team.staff.view.form</field>
<field name="model">sports.team.staff</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="staff_ids"><list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="role"/>
<field name="mobile" widget="phone"/>
<field column_invisible="True" name="has_portal_access"/>
<field column_invisible="True" name="partner_id"/>
<button class="oe_highlight" groups="bemade_sports_clinic.group_sports_clinic_admin,base.group_system" icon="fa-user-plus" invisible="has_portal_access == True" name="action_grant_portal_access" title="Grant Portal Access" type="object"/>
<button class="oe_highlight" groups="bemade_sports_clinic.group_sports_clinic_admin,base.group_system" icon="fa-ban" invisible="has_portal_access == False" name="action_revoke_portal_access" title="Revoke Portal Access" type="object"/>
</list></field>
<group>
<field name="partner_id"/>
<field name="parent_id"/>
<field name="role"/>
</group>
<group>
<field name="mobile" widget="phone"/>
<field name="email" widget="email"/>
</group>
</group>
</group>
<notebook>
<page name="players" string="Players">
<field name="patient_ids" nolabel="1"
context="{'list_view_ref': 'bemade_sports_clinic.sports_patient_view_list_embedded'}"/>
</page>
<page name="user_access"
string="User Access"
groups="bemade_sports_clinic.group_sports_clinic_admin,base.group_system">
<field name="allowed_user_ids"/>
</page>
</notebook>
</sheet>
<chatter reload_on_post="True"/>
</form>
</field>
</record>
<record id="sports_team_staff_view_form" model="ir.ui.view">
<field name="name">sports.team.staff.view.form</field>
<field name="model">sports.team.staff</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="partner_id"/>
<field name="parent_id"/>
<field name="role"/>
</group>
<group>
<field name="mobile" widget="phone"/>
<field name="email" widget="email"/>
</group>
</group>
</sheet>
</form></field>
</record>
</sheet>
</form>
</field>
</record>
</data>
</odoo>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Wizard Form View -->
<record id="view_team_role_mass_assign_wizard_form" model="ir.ui.view">
<field name="name">team.role.mass.assign.wizard.form</field>
@ -48,4 +49,5 @@
<field name="target">new</field>
<field name="context">{"default_user_id": active_id}</field>
</record>
</data>
</odoo>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Treatment Note List View -->
<record id="view_treatment_note_list" model="ir.ui.view">
<field name="name">sports.treatment.note.list</field>
@ -84,5 +85,5 @@
</p>
</field>
</record>
</data>
</odoo>