feat(fusion_portal): Phase 3 - assessment visit workspace (accessibility path)
Adds the portal workspace: /my/visit/new starts a visit; /my/visit/<id> shows the add-as-you-go workspace (add buttons -> existing forms carrying ?visit_id, a deferred client+funding form, and a Complete button). Accessibility forms launched from a visit save as a DRAFT linked to it (JS carries visit_id into the form; the controller captures it and skips the per-assessment SO) - the VISIT completion then creates the grouped per-funding sale orders. NOT YET: express/ADP form visit-linking, email consolidation, polished tablet UI. Untested locally (Enterprise dep) - clone verification pending. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,7 @@ This module provides external portal access for:
|
|||||||
'views/portal_technician_templates.xml',
|
'views/portal_technician_templates.xml',
|
||||||
'views/portal_book_assessment.xml',
|
'views/portal_book_assessment.xml',
|
||||||
'views/portal_page11_sign_templates.xml',
|
'views/portal_page11_sign_templates.xml',
|
||||||
|
'views/portal_visit.xml',
|
||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
|
|||||||
@@ -2479,6 +2479,56 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
template = template_map.get(assessment_type, 'fusion_portal.portal_accessibility_selector')
|
template = template_map.get(assessment_type, 'fusion_portal.portal_accessibility_selector')
|
||||||
return request.render(template, values)
|
return request.render(template, values)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# ASSESSMENT VISIT WORKSPACE (Phase 1b/3)
|
||||||
|
# ==========================================================================
|
||||||
|
@http.route('/my/visit/new', type='http', auth='user', website=True)
|
||||||
|
def visit_new(self, **kw):
|
||||||
|
"""Start a new assessment visit and open its workspace."""
|
||||||
|
partner = request.env.user.partner_id
|
||||||
|
if not partner.is_sales_rep_portal and not partner.is_authorizer:
|
||||||
|
return request.redirect('/my')
|
||||||
|
visit = request.env['fusion.assessment.visit'].sudo().create({
|
||||||
|
'sales_rep_id': request.env.user.id,
|
||||||
|
})
|
||||||
|
return request.redirect('/my/visit/%s' % visit.id)
|
||||||
|
|
||||||
|
@http.route('/my/visit/<int:visit_id>', type='http', auth='user', website=True)
|
||||||
|
def visit_workspace(self, visit_id, **kw):
|
||||||
|
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
|
||||||
|
if not visit.exists():
|
||||||
|
return request.redirect('/my')
|
||||||
|
return request.render('fusion_portal.portal_visit_workspace', {
|
||||||
|
'visit': visit,
|
||||||
|
'page_name': 'visit',
|
||||||
|
'error': kw.get('error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
@http.route('/my/visit/<int:visit_id>/save', type='http', auth='user', methods=['POST'], website=True, csrf=True)
|
||||||
|
def visit_save_client(self, visit_id, **post):
|
||||||
|
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
|
||||||
|
if visit.exists():
|
||||||
|
visit.write({
|
||||||
|
'client_name': (post.get('client_name') or '').strip(),
|
||||||
|
'client_phone': (post.get('client_phone') or '').strip(),
|
||||||
|
'client_email': (post.get('client_email') or '').strip(),
|
||||||
|
'client_address': (post.get('client_address') or '').strip(),
|
||||||
|
'x_fc_income_under_mod_threshold': post.get('x_fc_income_under_mod_threshold') or 'unknown',
|
||||||
|
})
|
||||||
|
return request.redirect('/my/visit/%s' % visit_id)
|
||||||
|
|
||||||
|
@http.route('/my/visit/<int:visit_id>/complete', type='http', auth='user', methods=['POST'], website=True, csrf=True)
|
||||||
|
def visit_complete(self, visit_id, **post):
|
||||||
|
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
|
||||||
|
if not visit.exists():
|
||||||
|
return request.redirect('/my')
|
||||||
|
try:
|
||||||
|
visit.action_complete_visit()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning("Visit %s completion failed: %s", visit_id, e)
|
||||||
|
return request.redirect('/my/visit/%s?error=%s' % (visit_id, str(e)))
|
||||||
|
return request.redirect('/my/visit/%s' % visit_id)
|
||||||
|
|
||||||
@http.route('/my/accessibility/save', type='json', auth='user', methods=['POST'], csrf=True)
|
@http.route('/my/accessibility/save', type='json', auth='user', methods=['POST'], csrf=True)
|
||||||
def accessibility_assessment_save(self, **post):
|
def accessibility_assessment_save(self, **post):
|
||||||
"""Save an accessibility assessment and optionally create a Sale Order"""
|
"""Save an accessibility assessment and optionally create a Sale Order"""
|
||||||
@@ -2547,6 +2597,13 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
if partner.is_authorizer:
|
if partner.is_authorizer:
|
||||||
vals['authorizer_id'] = partner.id
|
vals['authorizer_id'] = partner.id
|
||||||
|
|
||||||
|
# Link to a visit if this form was launched from the workspace.
|
||||||
|
if post.get('visit_id'):
|
||||||
|
try:
|
||||||
|
vals['visit_id'] = int(post.get('visit_id'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
# Create the assessment
|
# Create the assessment
|
||||||
assessment = Assessment.create(vals)
|
assessment = Assessment.create(vals)
|
||||||
_logger.info(f"Created accessibility assessment {assessment.reference} by {request.env.user.name}")
|
_logger.info(f"Created accessibility assessment {assessment.reference} by {request.env.user.name}")
|
||||||
@@ -2572,8 +2629,12 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
if video_data:
|
if video_data:
|
||||||
self._attach_accessibility_video(assessment, video_data, video_filename)
|
self._attach_accessibility_video(assessment, video_data, video_filename)
|
||||||
|
|
||||||
# Complete the assessment and create Sale Order if requested
|
# Complete the assessment and create Sale Order if requested.
|
||||||
|
# When launched from a visit, always save as a draft linked to the
|
||||||
|
# visit — the VISIT completion creates the grouped sale order(s).
|
||||||
create_sale_order = post.get('create_sale_order', True)
|
create_sale_order = post.get('create_sale_order', True)
|
||||||
|
if vals.get('visit_id'):
|
||||||
|
create_sale_order = False
|
||||||
if create_sale_order:
|
if create_sale_order:
|
||||||
sale_order = assessment.action_complete()
|
sale_order = assessment.action_complete()
|
||||||
return {
|
return {
|
||||||
@@ -2586,12 +2647,13 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
'redirect_url': f'/my/sales/case/{sale_order.id}',
|
'redirect_url': f'/my/sales/case/{sale_order.id}',
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
redirect_url = ('/my/visit/%s' % vals['visit_id']) if vals.get('visit_id') else '/my/accessibility/list'
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'assessment_id': assessment.id,
|
'assessment_id': assessment.id,
|
||||||
'assessment_ref': assessment.reference,
|
'assessment_ref': assessment.reference,
|
||||||
'message': f'Assessment {assessment.reference} saved as draft.',
|
'message': f'Assessment {assessment.reference} saved.',
|
||||||
'redirect_url': '/my/accessibility/list',
|
'redirect_url': redirect_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -388,6 +388,7 @@
|
|||||||
<small class="text-muted">Determines which sale order / funding workflow this case enters.</small>
|
<small class="text-muted">Determines which sale order / funding workflow this case enters.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="hidden" name="visit_id" id="acc_visit_id"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -647,6 +648,15 @@
|
|||||||
// Fallback if Google Maps not loaded
|
// Fallback if Google Maps not loaded
|
||||||
window.initAddressAutocomplete = window.initAddressAutocomplete || function() {};
|
window.initAddressAutocomplete = window.initAddressAutocomplete || function() {};
|
||||||
|
|
||||||
|
// Carry visit_id from the workspace launch (?visit_id=) into the form
|
||||||
|
(function() {
|
||||||
|
var _vid = new URLSearchParams(window.location.search).get('visit_id');
|
||||||
|
if (_vid) {
|
||||||
|
var f = document.getElementById('acc_visit_id');
|
||||||
|
if (f) { f.value = _vid; }
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
// Form submission
|
// Form submission
|
||||||
function saveAssessment(createSaleOrder) {
|
function saveAssessment(createSaleOrder) {
|
||||||
var form = document.getElementById('accessibility_form');
|
var form = document.getElementById('accessibility_form');
|
||||||
|
|||||||
115
fusion_portal/views/portal_visit.xml
Normal file
115
fusion_portal/views/portal_visit.xml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
<template id="portal_visit_workspace" name="Assessment Visit Workspace">
|
||||||
|
<t t-call="portal.portal_layout">
|
||||||
|
<t t-set="no_breadcrumbs" t-value="True"/>
|
||||||
|
<div class="container py-4">
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/my">Dashboard</a></li>
|
||||||
|
<li class="breadcrumb-item active">Assessment Visit</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<t t-if="error">
|
||||||
|
<div class="alert alert-danger"><i class="fa fa-exclamation-circle"/> <t t-esc="error"/></div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h3 class="mb-0"><i class="fa fa-clipboard text-primary"/> Visit <t t-esc="visit.name"/></h3>
|
||||||
|
<span class="badge bg-secondary"><span t-field="visit.state"/></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-if="visit.state == 'done'">
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<strong>Visit completed.</strong> Sale orders created:
|
||||||
|
<ul class="mb-0">
|
||||||
|
<t t-foreach="visit.sale_order_ids" t-as="so">
|
||||||
|
<li><a t-attf-href="/my/sales/case/{{so.id}}"><t t-esc="so.name"/></a> — <span t-field="so.x_fc_sale_type"/></li>
|
||||||
|
</t>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Assessments added this visit -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-white"><strong>Assessments this visit</strong> (<t t-esc="visit.assessment_count"/>)</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p t-if="not visit.assessment_count" class="text-muted mb-0">
|
||||||
|
Nothing added yet — use the buttons below to add what you're assessing.
|
||||||
|
</p>
|
||||||
|
<ul class="list-group" t-if="visit.assessment_count">
|
||||||
|
<t t-foreach="visit.adp_assessment_ids" t-as="a">
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="fa fa-wheelchair text-primary"/> ADP — <span t-field="a.equipment_type"/></span>
|
||||||
|
<span class="badge bg-light text-dark"><span t-field="a.state"/></span>
|
||||||
|
</li>
|
||||||
|
</t>
|
||||||
|
<t t-foreach="visit.accessibility_assessment_ids" t-as="a">
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span><span t-field="a.assessment_type"/> — <span t-field="a.x_fc_funding_source"/></span>
|
||||||
|
<span class="badge bg-light text-dark"><span t-field="a.state"/></span>
|
||||||
|
</li>
|
||||||
|
</t>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-if="visit.state != 'done'">
|
||||||
|
<!-- Add assessment -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-white"><strong>+ Add assessment</strong></div>
|
||||||
|
<div class="card-body d-flex flex-wrap gap-2">
|
||||||
|
<a class="btn btn-outline-primary" t-attf-href="/my/assessment/express?visit_id={{visit.id}}">Wheelchair / ADP</a>
|
||||||
|
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/stairlift/straight?visit_id={{visit.id}}">Straight Stair Lift</a>
|
||||||
|
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/stairlift/curved?visit_id={{visit.id}}">Curved Stair Lift</a>
|
||||||
|
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/vpl?visit_id={{visit.id}}">Platform / Porch Lift</a>
|
||||||
|
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/ceiling-lift?visit_id={{visit.id}}">Ceiling Lift</a>
|
||||||
|
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/ramp?visit_id={{visit.id}}">Custom Ramp</a>
|
||||||
|
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/bathroom?visit_id={{visit.id}}">Bathroom Mod</a>
|
||||||
|
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/tub-cutout?visit_id={{visit.id}}">Tub Cutout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client details (deferred) -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-white"><strong>Client details</strong>
|
||||||
|
<span class="text-muted small">— fill in after the therapist leaves</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form t-attf-action="/my/visit/{{visit.id}}/save" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3"><label class="form-label">Client Name</label>
|
||||||
|
<input type="text" name="client_name" class="form-control" t-att-value="visit.client_name"/></div>
|
||||||
|
<div class="col-md-6 mb-3"><label class="form-label">Phone</label>
|
||||||
|
<input type="text" name="client_phone" class="form-control" t-att-value="visit.client_phone"/></div>
|
||||||
|
<div class="col-md-6 mb-3"><label class="form-label">Email</label>
|
||||||
|
<input type="email" name="client_email" class="form-control" t-att-value="visit.client_email"/></div>
|
||||||
|
<div class="col-md-6 mb-3"><label class="form-label">Address</label>
|
||||||
|
<input type="text" name="client_address" class="form-control" t-att-value="visit.client_address"/></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" t-if="visit.has_mod_items">
|
||||||
|
<label class="form-label">Income under March of Dimes threshold?
|
||||||
|
<span class="text-muted small">(MOD covers up to $15k/person, lifetime)</span></label>
|
||||||
|
<select name="x_fc_income_under_mod_threshold" class="form-select">
|
||||||
|
<option value="unknown" t-att-selected="visit.x_fc_income_under_mod_threshold == 'unknown'">Unknown</option>
|
||||||
|
<option value="yes" t-att-selected="visit.x_fc_income_under_mod_threshold == 'yes'">Yes — under threshold (full $15k)</option>
|
||||||
|
<option value="no" t-att-selected="visit.x_fc_income_under_mod_threshold == 'no'">No — over threshold (may be denied/partial)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary">Save client details</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complete -->
|
||||||
|
<form t-attf-action="/my/visit/{{visit.id}}/complete" method="post" t-if="visit.assessment_count">
|
||||||
|
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">Complete visit & create sale orders →</button>
|
||||||
|
<p class="text-muted small mt-2">Creates one sale order per funding workflow (ADP / March of Dimes / private / ...).</p>
|
||||||
|
</form>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user