Created Create Event portal template for portal treatment professionals to be able to add events.

This commit is contained in:
Denis Durepos 2025-08-15 13:02:20 -04:00
parent aea07cb5a5
commit fd43de7443
6 changed files with 904 additions and 26 deletions

View file

@ -88,6 +88,7 @@ This module provides a complete sports medicine clinic management solution with
"views/portal_attachments_template.xml",
"views/portal_event_detail_template.xml",
"views/portal_event_edit_template.xml",
"views/portal_event_create_template.xml",
"views/treatment_note_views.xml",
"views/res_partner_views.xml",
"views/res_users_views.xml",

View file

@ -447,3 +447,170 @@ class EventsPortal(CustomerPortal, AccessControlMixin):
# Clean error message to prevent redirect issues with newlines
error_msg = str(e).replace('\n', ' ').replace('\r', ' ')
return http.request.redirect(f'/my/event/{event_id}/edit?error={error_msg}')
@http.route(['/my/event/create'], type='http', auth='user', website=True)
def create_event_form(self, **kw):
"""Display event creation form - only accessible to therapists"""
# Access: only therapists (or system) can create
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 create events."))
# Options for form
teams = self._get_accessible_teams() if not user.has_group('base.group_system') else http.request.env['sports.team'].search([])
treatment_professionals = self._get_treatment_professionals()
venues = http.request.env['res.partner'].search([('is_venue', '=', True)])
values = {
'teams': teams,
'treatment_professionals': treatment_professionals,
'venues': venues,
'page_name': 'event_create',
}
return http.request.render('bemade_sports_clinic.portal_event_create', values)
@http.route(['/my/event/create/submit'], type='http', auth='user', website=True, methods=['POST'], csrf=False)
def create_event_submit(self, **post):
"""Handle event creation - only accessible to therapists"""
# Access: only therapists (or system) can create
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 create events."))
try:
create_vals = {}
# Required fields
if 'name' in post and post['name']:
create_vals['name'] = post['name']
else:
raise UserError(_('Event name is required.'))
if 'team_id' in post and post['team_id']:
create_vals['team_id'] = int(post['team_id'])
else:
raise UserError(_('Team is required.'))
# Optional simple fields
if 'description' in post:
create_vals['description'] = post['description']
if 'venue_id' in post and post['venue_id']:
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']
# Datetime fields
def _parse_dt(val):
if not val:
return False
try:
if 'T' in val:
return datetime.strptime(val, '%Y-%m-%dT%H:%M')
return datetime.strptime(val, '%Y-%m-%d %H:%M')
except ValueError:
try:
if 'T' in val:
return datetime.strptime(val, '%Y-%m-%dT%H:%M:%S')
return datetime.strptime(val, '%Y-%m-%d %H:%M:%S')
except ValueError:
return val
if 'date_start' in post and post['date_start']:
create_vals['date_start'] = _parse_dt(post['date_start'])
else:
raise UserError(_('Event start time is required.'))
if 'date_end' in post and post['date_end']:
create_vals['date_end'] = _parse_dt(post['date_end'])
else:
raise UserError(_('Event end time is required.'))
if 'therapist_start' in post and post['therapist_start']:
create_vals['therapist_start'] = _parse_dt(post['therapist_start'])
if 'therapist_end' in post and post['therapist_end']:
create_vals['therapist_end'] = _parse_dt(post['therapist_end'])
# Assigned staff (same handling as save_event)
staff_param = None
if 'assigned_staff_ids' in post:
staff_param = post['assigned_staff_ids']
elif 'assigned_staff_ids[]' in post:
staff_param = post['assigned_staff_ids[]']
staff_ids = []
if staff_param is not None:
_logger.info(f"Raw assigned_staff_ids from form: {staff_param} (type: {type(staff_param)})")
if isinstance(staff_param, list):
staff_ids = [int(x) for x in staff_param if x]
elif staff_param:
if ',' in str(staff_param):
staff_ids = [int(x.strip()) for x in str(staff_param).split(',') if x.strip()]
else:
staff_ids = [int(staff_param)]
create_vals['assigned_staff_ids'] = [(6, 0, staff_ids)]
# Task creation is handled by the model's create() default logic
event = http.request.env['sports.event'].create(create_vals)
# Surgical sudo: create management task as superuser to bypass project/analytic ACLs
try:
event.sudo().create_management_task()
except Exception as task_err:
_logger.warning(f"Portal task creation failed for event {event.id}: {task_err}")
return http.request.redirect(f'/my/event/{event.id}?created=1')
except Exception as e:
error_msg = str(e).replace('\n', ' ').replace('\r', ' ')
return http.request.redirect(f"/my/event/create?error={error_msg}")
@http.route(['/my/venue/create'], type='json', auth='user', website=True, methods=['POST'], csrf=False)
def create_venue_ajax(self, **post):
"""Create a venue partner record via AJAX for portal users.
Uses surgical sudo to avoid ACL/record rule issues, but restricts fields strictly.
"""
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')):
return {'success': False, 'error': _("You don't have permission to create venues.")}
name = (post.get('name') or '').strip()
if not name:
return {'success': False, 'error': _('Venue name is required.')}
try:
vals = {
'name': name,
'is_company': True,
'is_venue': True,
'type': 'other',
}
# Optional address fields
for key in ['street', 'street2', 'city', 'zip']:
if post.get(key):
vals[key] = post.get(key)
# Optional country/state by id
if post.get('country_id'):
try:
vals['country_id'] = int(post.get('country_id'))
except Exception:
pass
if post.get('state_id'):
try:
vals['state_id'] = int(post.get('state_id'))
except Exception:
pass
venue = http.request.env['res.partner'].sudo().create(vals)
return {'success': True, 'id': venue.id, 'name': venue.name}
except Exception as e:
return {'success': False, 'error': str(e)}

View file

@ -37,6 +37,7 @@ class SportsEvent(models.Model):
tracking=True,
index=True,
groups=_portal_groups,
default=fields.Datetime.now,
help='When the event starts'
)
@ -90,7 +91,7 @@ class SportsEvent(models.Model):
('in_progress', 'In Progress'),
('completed', 'Completed'),
('cancelled', 'Cancelled')
], string='Status', default='draft', tracking=True, groups=_portal_groups)
], string='Status', default='confirmed', tracking=True, groups=_portal_groups)
# ========================================
# RELATIONSHIPS (Portal Accessible)
@ -245,18 +246,27 @@ class SportsEvent(models.Model):
# ONCHANGE METHODS
# ========================================
@api.onchange('date_start', 'date_end')
def _onchange_event_dates(self):
"""Set default therapist times when event dates change"""
if self.date_start and self.date_end:
# Only set therapist times if they are currently blank
if not self.therapist_start:
# Set therapist start to 30 minutes before event start
from datetime import timedelta
self.therapist_start = self.date_start - timedelta(minutes=30)
if not self.therapist_end:
# Set therapist end to event end time
@api.onchange('date_start')
def _onchange_date_start(self):
"""When event start changes:
- therapist_start = 30 minutes prior
- 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=30)
self.date_end = self.date_start + timedelta(hours=2)
self.therapist_end = self.date_end
@api.onchange('date_end')
def _onchange_date_end(self):
"""When event end changes:
- therapist_end = max(therapist_end, date_end)
"""
if self.date_end:
# If therapist_end not set or earlier than date_end, align to date_end
if not self.therapist_end or self.therapist_end < self.date_end:
self.therapist_end = self.date_end
# ========================================
@ -365,9 +375,13 @@ class SportsEvent(models.Model):
events = super().create(vals_list)
# Auto-create management tasks for events that need them
for event, vals in zip(events, vals_list):
if vals.get('auto_create_task', True):
event.create_management_task()
# IMPORTANT: Only internal users (base.group_user) may auto-create tasks here.
# Portal users will have tasks created via a controller-side sudo() after access checks.
is_internal = self.env.user.has_group('base.group_user')
if is_internal:
for event, vals in zip(events, vals_list):
if vals.get('auto_create_task', True):
event.create_management_task()
return events

View file

@ -25,7 +25,7 @@
</div>
<!-- View Type Navigation -->
<div class="row mb-3">
<div class="col-12">
<div class="col-md-6">
<ul class="nav nav-pills">
<li class="nav-item">
<a t-attf-class="nav-link #{'active' if view_type == 'all' else ''}"
@ -48,9 +48,14 @@
</ul>
</div>
<div class="col-md-6 text-end">
<a href="/my/events?view_type=all&amp;no_default_dates=1" class="btn btn-outline-secondary btn-sm">
<a href="/my/events?view_type=all&amp;no_default_dates=1" class="btn btn-outline-secondary btn-sm me-2">
<i class="fa fa-times"/> Clear Filters
</a>
<t t-if="can_edit">
<a href="/my/event/create" class="btn btn-primary btn-sm">
<i class="fa fa-plus"/> Add Event
</a>
</t>
</div>
</div>

View file

@ -0,0 +1,495 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<!-- Event Create Template -->
<template id="portal_event_create" name="Portal Event Create">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="container-fluid">
<!-- Breadcrumbs -->
<div class="row">
<div class="col-lg-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="/my">Home</a>
</li>
<li class="breadcrumb-item">
<a href="/my/events">Events</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Create</li>
</ol>
</nav>
</div>
</div>
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2>Create Event</h2>
<p class="text-muted mb-0">
<i class="fa fa-plus"/> New sports event
</p>
</div>
<div>
<a href="/my/events" class="btn btn-outline-secondary">
<i class="fa fa-arrow-left"/> Cancel
</a>
</div>
</div>
</div>
</div>
<!-- Error Messages -->
<t t-if="request.params.get('error')">
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fa fa-exclamation-circle"/> Error: <span t-esc="request.params.get('error')"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
</t>
<!-- Create Form -->
<form action="/my/event/create/submit" method="post" class="needs-validation" novalidate="true">
<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 Information
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<!-- Event Name -->
<div class="mb-3">
<label for="name" class="form-label">Event Name *</label>
<input type="text" class="form-control" id="name" name="name" required="true"/>
<div class="invalid-feedback">
Please provide a valid event name.
</div>
</div>
<!-- Event Type -->
<div class="mb-3">
<label for="event_type" class="form-label">Event Type</label>
<select class="form-select" id="event_type" name="event_type">
<option value="game">Game</option>
<option value="practice">Practice</option>
<option value="training">Training</option>
<option value="meeting">Team Meeting</option>
<option value="other">Other</option>
</select>
</div>
<!-- Status -->
<div class="mb-3">
<label for="state" class="form-label">Status</label>
<select class="form-select" id="state" name="state">
<option value="draft">Draft</option>
<option value="confirmed" selected="selected">Confirmed</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
<div class="col-md-6">
<!-- Team -->
<div class="mb-3">
<label for="team_id" class="form-label">Team *</label>
<select class="form-select" id="team_id" name="team_id" required="true">
<option value="">Select a team...</option>
<t t-foreach="teams" t-as="team">
<option t-att-value="team.id">
<t t-esc="team.name"/>
</option>
</t>
</select>
<div class="invalid-feedback">
Please select a team.
</div>
</div>
<!-- Venue -->
<div class="mb-3">
<label for="venue_id" class="form-label">Venue</label>
<div class="d-flex gap-2 align-items-start">
<select class="form-select" id="venue_id" name="venue_id">
<option value="">Select a venue...</option>
<t t-foreach="venues" t-as="venue">
<option t-att-value="venue.id">
<t t-esc="venue.name"/>
</option>
</t>
</select>
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addVenueModal">
<i class="fa fa-plus"/> Add Venue
</button>
</div>
</div>
</div>
</div>
<!-- Description -->
<div class="mb-3">
<label for="description_editor" class="form-label">Description</label>
<!-- Hidden field actually submitted -->
<input type="hidden" id="description" name="description" value=""/>
<!-- Simple rich-text editor -->
<div id="description_editor"
class="form-control"
style="min-height: 150px; overflow:auto;"
contenteditable="true">
</div>
<small class="form-text text-muted">You can paste formatted text. The formatted content will be saved.</small>
</div>
<script type="text/javascript">
(function() {
const form = document.currentScript &amp;&amp; document.currentScript.closest('form');
if (!form) return;
const editor = form.querySelector('#description_editor');
const hidden = form.querySelector('#description');
if (!editor || !hidden) return;
form.addEventListener('submit', function() {
hidden.value = editor.innerHTML;
});
})();
</script>
</div>
</div>
<!-- Timing Information -->
<div class="card mt-3">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa fa-clock-o"/> Timing Information
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<!-- Event Start -->
<div class="mb-3">
<label for="date_start" class="form-label">Event Start *</label>
<input type="datetime-local" class="form-control" id="date_start" name="date_start" required="true"/>
<div class="invalid-feedback">
Please provide an event start time.
</div>
</div>
<!-- Therapist Start -->
<div class="mb-3">
<label for="therapist_start" class="form-label">Therapist Start</label>
<input type="datetime-local" class="form-control" id="therapist_start" name="therapist_start"/>
<small class="form-text text-muted">Leave empty to use event start time</small>
</div>
</div>
<div class="col-md-6">
<!-- Event End -->
<div class="mb-3">
<label for="date_end" class="form-label">Event End *</label>
<input type="datetime-local" class="form-control" id="date_end" name="date_end" required="true"/>
<div class="invalid-feedback">
Please provide an event end time.
</div>
</div>
<!-- Therapist End -->
<div class="mb-3">
<label for="therapist_end" class="form-label">Therapist End</label>
<input type="datetime-local" class="form-control" id="therapist_end" name="therapist_end"/>
<small class="form-text text-muted">Leave empty to use event end time</small>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Assigned Staff -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa fa-user-md"/> Assigned Staff
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Treatment Professionals</label>
<t t-foreach="treatment_professionals" t-as="professional">
<div class="form-check">
<input class="form-check-input" type="checkbox"
t-att-id="'staff_%s' % professional.id"
name="assigned_staff_ids[]"
t-att-value="professional.id"/>
<label class="form-check-label" t-att-for="'staff_%s' % professional.id">
<span t-esc="professional.name"/>
<small class="text-muted d-block" t-esc="professional.email"/>
</label>
</div>
</t>
</div>
</div>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-end">
<a href="/my/events" class="btn btn-outline-secondary me-2">
<i class="fa fa-times"/> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="fa fa-save"/> Create Event
</button>
</div>
</div>
</div>
</form>
</div>
<!-- Add Venue Modal -->
<div class="modal fade" id="addVenueModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Venue</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name *</label>
<input type="text" class="form-control" id="venue_name" required="required"/>
</div>
<div class="mb-3">
<label class="form-label">Street</label>
<input type="text" class="form-control" id="venue_street"/>
</div>
<div class="mb-3">
<label class="form-label">Street 2</label>
<input type="text" class="form-control" id="venue_street2"/>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">City</label>
<input type="text" class="form-control" id="venue_city"/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">ZIP</label>
<input type="text" class="form-control" id="venue_zip"/>
</div>
</div>
<div class="alert alert-danger d-none" id="venue_error"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveVenueBtn">Save Venue</button>
</div>
</div>
</div>
</div>
<!-- Form Validation and Venue AJAX Script -->
<script>
(function() {
'use strict';
window.addEventListener('load', function() {
var forms = document.getElementsByClassName('needs-validation');
var validation = Array.prototype.filter.call(forms, function(form) {
form.addEventListener('submit', function(event) {
if (form.checkValidity() === false) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated');
// Gather selected staff into a single hidden field (same pattern as edit)
var allStaffCheckboxes = form.querySelectorAll('input[name="assigned_staff_ids[]"]');
var checkedStaff = form.querySelectorAll('input[name="assigned_staff_ids[]"]:checked');
var staffValues = [];
checkedStaff.forEach(function(checkbox) {
staffValues.push(checkbox.value);
});
// Remove checkbox names to avoid duplicate submission
allStaffCheckboxes.forEach(function(cb) { cb.removeAttribute('name'); });
var hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'assigned_staff_ids';
hiddenInput.value = staffValues.join(',');
form.appendChild(hiddenInput);
}, false);
});
}, false);
// Add Venue AJAX handler using JSON-RPC
document.addEventListener('DOMContentLoaded', function() {
var saveBtn = document.getElementById('saveVenueBtn');
if (!saveBtn) return;
saveBtn.addEventListener('click', function() {
var name = document.getElementById('venue_name').value.trim();
var street = document.getElementById('venue_street').value.trim();
var street2 = document.getElementById('venue_street2').value.trim();
var city = document.getElementById('venue_city').value.trim();
var zip = document.getElementById('venue_zip').value.trim();
var errorBox = document.getElementById('venue_error');
errorBox.classList.add('d-none');
errorBox.textContent = '';
if (!name) {
errorBox.textContent = 'Venue name is required.';
errorBox.classList.remove('d-none');
return;
}
fetch('/my/venue/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: { name: name, street: street, street2: street2, city: city, zip: zip } })
})
.then(function(resp) { return resp.json(); })
.then(function(data) {
var result = data &amp;&amp; (data.result || data);
if (!result || result.success === false) {
errorBox.textContent = (result &amp;&amp; result.error) || 'Failed to create venue.';
errorBox.classList.remove('d-none');
return;
}
var select = document.getElementById('venue_id');
if (select) {
var opt = document.createElement('option');
opt.value = result.id;
opt.textContent = result.name;
select.appendChild(opt);
select.value = String(result.id);
}
// Close modal (robust: works with or without global bootstrap)
var modalEl = document.getElementById('addVenueModal');
if (modalEl) {
try {
if (window &amp;&amp; window.bootstrap &amp;&amp; window.bootstrap.Modal) {
var modal = window.bootstrap.Modal.getInstance(modalEl) || new window.bootstrap.Modal(modalEl);
modal.hide();
} else {
var closeBtn = modalEl.querySelector('[data-bs-dismiss="modal"]');
if (closeBtn) {
closeBtn.click();
} else {
modalEl.classList.remove('show');
modalEl.setAttribute('aria-hidden', 'true');
modalEl.style.display = 'none';
var backdrop = document.querySelector('.modal-backdrop');
if (backdrop) backdrop.parentNode.removeChild(backdrop);
document.body.classList.remove('modal-open');
document.body.style.removeProperty('overflow');
document.body.style.removeProperty('padding-right');
}
}
} catch (e) { /* no-op */ }
}
})
.catch(function(err) {
errorBox.textContent = 'Error: ' + err;
errorBox.classList.remove('d-none');
});
});
});
// Date defaults and onchange behavior for portal form
document.addEventListener('DOMContentLoaded', function() {
var dateStart = document.getElementById('date_start');
var therapistStart = document.getElementById('therapist_start');
var dateEnd = document.getElementById('date_end');
var therapistEnd = document.getElementById('therapist_end');
function pad(n) { return n &lt; 10 ? '0' + n : '' + n; }
function formatLocal(dt) {
return dt.getFullYear() + '-' + pad(dt.getMonth()+1) + '-' + pad(dt.getDate()) + 'T' + pad(dt.getHours()) + ':' + pad(dt.getMinutes());
}
function addMinutes(dt, mins) {
var d = new Date(dt.getTime());
d.setMinutes(d.getMinutes() + mins);
return d;
}
function parseLocal(value) {
// value like 'YYYY-MM-DDTHH:MM'
// Construct as local time
var parts = value.split('T');
if (!parts || parts.length !== 2) return null;
var d = parts[0].split('-');
var t = parts[1].split(':');
if (d.length !== 3 || t.length &lt; 2) return null;
return new Date(
parseInt(d[0],10),
parseInt(d[1],10)-1,
parseInt(d[2],10),
parseInt(t[0],10),
parseInt(t[1],10),
0,
0
);
}
// Set default date_start to now if empty
if (dateStart &amp;&amp; !dateStart.value) {
var now = new Date();
dateStart.value = formatLocal(now);
}
function onDateStartChanged() {
if (!(dateStart &amp;&amp; dateStart.value)) return;
var startDt = parseLocal(dateStart.value);
if (!startDt) return;
if (therapistStart) {
therapistStart.value = formatLocal(addMinutes(startDt, -30));
}
if (dateEnd) {
var endDt = addMinutes(startDt, 120);
dateEnd.value = formatLocal(endDt);
}
if (therapistEnd &amp;&amp; dateEnd &amp;&amp; dateEnd.value) {
therapistEnd.value = dateEnd.value;
}
}
function onDateEndChanged() {
if (!(dateEnd &amp;&amp; dateEnd.value)) return;
var endDt = parseLocal(dateEnd.value);
if (!endDt) return;
if (therapistEnd) {
if (!therapistEnd.value) {
therapistEnd.value = dateEnd.value;
} else {
var tEnd = parseLocal(therapistEnd.value);
if (tEnd &amp;&amp; tEnd.getTime() &lt; endDt.getTime()) {
therapistEnd.value = dateEnd.value;
}
}
}
}
if (dateStart) {
dateStart.addEventListener('change', onDateStartChanged);
// Also run once on load to initialize dependent fields
onDateStartChanged();
}
if (dateEnd) {
dateEnd.addEventListener('change', onDateEndChanged);
}
});
})();
</script>
</t>
</template>
</data>
</odoo>

View file

@ -124,14 +124,19 @@
<!-- Venue -->
<div class="mb-3">
<label for="venue_id" class="form-label">Venue</label>
<select class="form-select" id="venue_id" name="venue_id">
<option value="">Select a venue...</option>
<t t-foreach="venues" t-as="venue">
<option t-att-value="venue.id" t-att-selected="event.venue_id.id == venue.id">
<t t-esc="venue.name"/>
</option>
</t>
</select>
<div class="d-flex gap-2 align-items-start">
<select class="form-select" id="venue_id" name="venue_id">
<option value="">Select a venue...</option>
<t t-foreach="venues" t-as="venue">
<option t-att-value="venue.id" t-att-selected="event.venue_id.id == venue.id">
<t t-esc="venue.name"/>
</option>
</t>
</select>
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addVenueModal">
<i class="fa fa-plus"/> Add Venue
</button>
</div>
</div>
</div>
</div>
@ -266,6 +271,47 @@
</form>
</div>
<!-- Add Venue Modal -->
<div class="modal fade" id="addVenueModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Venue</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name *</label>
<input type="text" class="form-control" id="venue_name" required="required"/>
</div>
<div class="mb-3">
<label class="form-label">Street</label>
<input type="text" class="form-control" id="venue_street"/>
</div>
<div class="mb-3">
<label class="form-label">Street 2</label>
<input type="text" class="form-control" id="venue_street2"/>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">City</label>
<input type="text" class="form-control" id="venue_city"/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">ZIP</label>
<input type="text" class="form-control" id="venue_zip"/>
</div>
</div>
<div class="alert alert-danger d-none" id="venue_error"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveVenueBtn">Save Venue</button>
</div>
</div>
</div>
</div>
<!-- Form Validation Script -->
<script>
(function() {
@ -324,6 +370,156 @@
}, false);
})();
</script>
<!-- Venue AJAX Script -->
<script>
(function() {
document.addEventListener('DOMContentLoaded', function() {
var saveBtn = document.getElementById('saveVenueBtn');
if (!saveBtn) return;
saveBtn.addEventListener('click', function() {
var name = document.getElementById('venue_name').value.trim();
var street = document.getElementById('venue_street').value.trim();
var street2 = document.getElementById('venue_street2').value.trim();
var city = document.getElementById('venue_city').value.trim();
var zip = document.getElementById('venue_zip').value.trim();
var errorBox = document.getElementById('venue_error');
errorBox.classList.add('d-none');
errorBox.textContent = '';
if (!name) {
errorBox.textContent = 'Venue name is required.';
errorBox.classList.remove('d-none');
return;
}
fetch('/my/venue/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: { name: name, street: street, street2: street2, city: city, zip: zip } })
})
.then(function(resp) { return resp.json(); })
.then(function(data) {
var result = data &amp;&amp; (data.result || data);
if (!result || result.success === false) {
errorBox.textContent = (result &amp;&amp; result.error) || 'Failed to create venue.';
errorBox.classList.remove('d-none');
return;
}
var select = document.getElementById('venue_id');
if (select) {
var opt = document.createElement('option');
opt.value = result.id;
opt.textContent = result.name;
select.appendChild(opt);
select.value = String(result.id);
}
// Close modal (robust: works with or without global bootstrap)
var modalEl = document.getElementById('addVenueModal');
if (modalEl) {
try {
if (window &amp;&amp; window.bootstrap &amp;&amp; window.bootstrap.Modal) {
var modal = window.bootstrap.Modal.getInstance(modalEl) || new window.bootstrap.Modal(modalEl);
modal.hide();
} else {
var closeBtn = modalEl.querySelector('[data-bs-dismiss="modal"]');
if (closeBtn) {
closeBtn.click();
} else {
modalEl.classList.remove('show');
modalEl.setAttribute('aria-hidden', 'true');
modalEl.style.display = 'none';
var backdrop = document.querySelector('.modal-backdrop');
if (backdrop) backdrop.parentNode.removeChild(backdrop);
document.body.classList.remove('modal-open');
document.body.style.removeProperty('overflow');
document.body.style.removeProperty('padding-right');
}
}
} catch (e) { /* no-op */ }
}
})
.catch(function(err) {
errorBox.textContent = 'Error: ' + err;
errorBox.classList.remove('d-none');
});
});
});
// Date defaults and onchange behavior for portal edit form
document.addEventListener('DOMContentLoaded', function() {
var dateStart = document.getElementById('date_start');
var therapistStart = document.getElementById('therapist_start');
var dateEnd = document.getElementById('date_end');
var therapistEnd = document.getElementById('therapist_end');
function pad(n) { return n &lt; 10 ? '0' + n : '' + n; }
function formatLocal(dt) {
return dt.getFullYear() + '-' + pad(dt.getMonth()+1) + '-' + pad(dt.getDate()) + 'T' + pad(dt.getHours()) + ':' + pad(dt.getMinutes());
}
function addMinutes(dt, mins) {
var d = new Date(dt.getTime());
d.setMinutes(d.getMinutes() + mins);
return d;
}
function parseLocal(value) {
var parts = value.split('T');
if (!parts || parts.length !== 2) return null;
var d = parts[0].split('-');
var t = parts[1].split(':');
if (d.length !== 3 || t.length &lt; 2) return null;
return new Date(
parseInt(d[0],10),
parseInt(d[1],10)-1,
parseInt(d[2],10),
parseInt(t[0],10),
parseInt(t[1],10),
0,
0
);
}
function onDateStartChanged() {
if (!(dateStart &amp;&amp; dateStart.value)) return;
var startDt = parseLocal(dateStart.value);
if (!startDt) return;
if (therapistStart) {
therapistStart.value = formatLocal(addMinutes(startDt, -30));
}
if (dateEnd) {
var endDt = addMinutes(startDt, 120);
dateEnd.value = formatLocal(endDt);
}
if (therapistEnd &amp;&amp; dateEnd &amp;&amp; dateEnd.value) {
therapistEnd.value = dateEnd.value;
}
}
function onDateEndChanged() {
if (!(dateEnd &amp;&amp; dateEnd.value)) return;
var endDt = parseLocal(dateEnd.value);
if (!endDt) return;
if (therapistEnd) {
if (!therapistEnd.value) {
therapistEnd.value = dateEnd.value;
} else {
var tEnd = parseLocal(therapistEnd.value);
if (tEnd &amp;&amp; tEnd.getTime() &lt; endDt.getTime()) {
therapistEnd.value = dateEnd.value;
}
}
}
}
if (dateStart) {
dateStart.addEventListener('change', onDateStartChanged);
}
if (dateEnd) {
dateEnd.addEventListener('change', onDateEndChanged);
}
});
})();
</script>
</t>
</template>
</data>