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:
parent
dffb2755db
commit
cbd9f2faab
25 changed files with 1442 additions and 193 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
220
bemade_sports_clinic/controllers/timesheets_portal.py
Normal file
220
bemade_sports_clinic/controllers/timesheets_portal.py
Normal 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')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
186
bemade_sports_clinic/models/sports_event_timesheet.py
Normal file
186
bemade_sports_clinic/models/sports_event_timesheet.py
Normal 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.')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
273
bemade_sports_clinic/views/portal_timesheets_templates.xml
Normal file
273
bemade_sports_clinic/views/portal_timesheets_templates.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -233,6 +233,5 @@
|
|||
</form>
|
||||
</field>
|
||||
</record>
|
||||
>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue