Compare commits

..

30 Commits

Author SHA1 Message Date
f3766c2898 feat: add x_fc_authorizer_number, x_fc_account_number, x_marked_for fields; auto-link authorizer from XML
- fusion_claims: added x_fc_authorizer_number to res.partner for ADP authorizer registration numbers
- fusion_claims: XML parser auto-links authorizer contact to sale order by ADP number
- fusion_claims: removed size=9 constraint from x_fc_odsp_member_id
- fusion_claims: authorizer number shown on OT/PT contact form
- fusion_so_to_po: added x_marked_for (Many2one) field definition on purchase.order
- fusion_so_to_po: added x_fc_account_number on res.partner for vendor account numbers
2026-03-11 17:22:02 +00:00
431052920e feat: separate fusion field service and LTC into standalone modules, update core modules
- fusion_claims: separated field service logic, updated controllers/views
- fusion_tasks: updated task views and map integration
- fusion_authorizer_portal: added page 11 signing, schedule booking, migrations
- fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator)
- fusion_ltc_management: new standalone LTC management module
2026-03-11 16:19:52 +00:00
1f79cdcaaf fix: improve AI chat table rendering with CSS styling and narrow-panel formatting
- Add SCSS for AI chat tables: borders, padding, zebra striping, hover, dark mode
- Style headings, code, bold text, and lists in chat messages
- Update system prompt: enforce 3-column max tables for narrow chat panel
- Use key-value (2-column) tables for summaries, split wide data into sections
- Provide explicit correct/wrong format examples in prompt
2026-03-10 02:54:04 +00:00
8761d0e7c7 feat: add Demographics & Analytics tool to Fusion Claims Intelligence
- Add Tool 6 for demographic analysis using direct SQL queries
- Age group breakdowns: clients, applications, avg apps/client, avg funding
- Device popularity by age bracket (under 45, 45-60, 61-75, 75+)
- City demographics with average age and funding per city
- Benefit type analysis (ODSP, OWP, ACSD, Regular)
- Top devices with average client age
- Overall funding summary (totals, averages, age range)
- Update AI topic and system prompt with Tool 6 routing examples
2026-03-10 02:45:51 +00:00
0053576cc2 fix: enable rich text markdown formatting for AI agent responses
- Install markdown2 dependency for Odoo AI module
- Update system prompt with explicit markdown formatting instructions
- Add example templates for client status and billing period responses
- Use tables, bold, headings, and code formatting for clean output
2026-03-10 02:39:02 +00:00
7bd7b8f7c4 fix: enhance Fusion Claims Intelligence AI with client status and billing period tools
- Fix _read_group override crash (dict_values not subscriptable) in sale_order.py
- Migrate _fc_tool_claims_stats from deprecated read_group() to _read_group() API
- Enrich client details tool with funding history, invoice status, prev-funded devices
- Add Client Status Lookup tool (search by name, returns orders/invoices/next steps)
- Add ADP Billing Period tool (invoiced amounts, paid/unpaid, submission deadlines)
- Update AI agent system prompt with all 5 tools and usage examples
2026-03-10 02:30:42 +00:00
3342b57469 feat: reorder search views - Customer first, add delivery/tags/status fields for ADP, ODSP, MOD 2026-03-10 01:46:15 +00:00
1bfa50aa5f feat: View Details uses ADP landscape report for ADP orders, add route decorators 2026-03-09 22:55:53 +00:00
85367747a6 fix: remove _get_display_grouped_section() call causing 500 error on portal 2026-03-09 22:49:55 +00:00
d7657bb356 feat: add borders, ADP Device Code, ADP/Client Portion columns and subtotals to portal view 2026-03-09 22:46:16 +00:00
9dac39853f fix: revert POD signature to original layout - only quotation reports need organized signature 2026-03-09 22:32:27 +00:00
c1a3b02ac5 fix: improve ADP report signature section with legal terms, date/time, printed name; switch portal to landscape report 2026-03-09 22:03:46 +00:00
1f750a6db4 fix: improve ADP report signature section with legal terms, date/time, printed name; switch portal to landscape report 2026-03-09 22:03:28 +00:00
ffcc83d7bd fix: improve ADP report signature section with legal terms, date/time, printed name; switch portal to landscape report 2026-03-09 22:03:12 +00:00
6c3c565440 fix: improve ADP report signature section with legal terms, date/time, printed name; switch portal to landscape report 2026-03-09 22:02:53 +00:00
1c191a54e1 fix: ADP portal sign/pay modal text - show client portion, not full total 2026-03-09 21:34:55 +00:00
512aedce69 fix: ADP portal - sidebar amount, claim details, signature report 2026-03-09 21:25:37 +00:00
f362fbd915 fix: ADP portal - sidebar amount, claim details, signature report 2026-03-09 21:25:05 +00:00
Nexa Agent
35399170b3 fix: ADP portal payment uses client portion instead of full order total
When customers pay for ADP quotations through the portal, the system
was charging the full order amount (ADP + client portions combined).
Now correctly charges only the client portion (25% for REG clients).

Changes:
- Override _get_prepayment_required_amount() to return client portion
- Override _has_to_be_paid() to skip payment for 100% ADP-funded orders
- Add portal controller to cap payment amount at client portion
- Add portal template showing ADP funding breakdown to customer
2026-03-09 21:11:19 +00:00
gsinghpal
3b3c57205a feat: add fusion_tasks module for field service management
Standalone module extracted from fusion_claims providing technician
scheduling, route simulation with Google Maps, GPS tracking, and
cross-instance task sync between odoo-westin and odoo-mobility.

Includes fix for route simulation: added Directions API error logging
to diagnose silent failures from conflicting Google Maps API keys.

Made-with: Cursor
2026-03-09 16:56:53 -04:00
gsinghpal
b649246e81 fix: auto-set rental sale type via onchange and context
is_rental_order is a computed field, not passed in vals during create.
Use in_rental_app context flag in create() and add onchange handler
so sale type sets to 'Rentals' immediately when toggled in the form.

Made-with: Cursor
2026-02-26 08:04:06 -05:00
gsinghpal
14fe9ab716 feat: hide authorizer for rental orders, auto-set sale type
Rental orders no longer show the "Authorizer Required?" question or
the Authorizer field. The sale type is automatically set to 'Rentals'
when creating or confirming a rental order. Validation logic also
skips authorizer checks for rental sale type.

Made-with: Cursor
2026-02-25 23:33:23 -05:00
gsinghpal
3c8f83b8e6 fix: remove invalid category_id from res.groups (not supported in Odoo 19)
Odoo 19 replaced category_id with privilege_id on res.groups.
Keep only privilege_id=False to clear it from the dropdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 12:55:17 -05:00
gsinghpal
4384987b82 fix: move Document Lock Override out of privilege dropdown
Rename to "Fusion: Document Lock Override" for clarity, clear
privilege_id so it appears under extra permissions instead of the
hierarchy dropdown, and add a descriptive tooltip explaining its
temporary nature and dependency on the settings toggle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 12:54:40 -05:00
gsinghpal
de8e3a83bb fix: explicitly clear privilege_id on portal groups to remove from dropdown
Setting privilege_id eval="False" forces Odoo to null out the existing
database value on upgrade. Simply omitting the field did not clear it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 12:45:53 -05:00
gsinghpal
3e59f9d5f6 fix: simplify fusion_claims permission dropdown and restrict settings access
Remove privilege_id from portal groups so they no longer appear in the
User settings dropdown (they are auto-assigned from Contact form).
Restrict Fusion Claims settings view to managers only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 12:41:20 -05:00
gsinghpal
34e5b46025 fix: comprehensive permission overhaul for fusion_faxes and fusion_ringcentral
Users without fax/RC groups could not open Sale Orders, Invoices, or
Contacts because the One2many computed fields triggered AccessError
on fusion.fax. Now base.group_user gets read-only access so computed
fields work silently, while all UI elements (smart buttons, header
buttons, menus, partner fields, settings) are restricted to the
proper security groups. Both modules now use Odoo 19 privilege
pattern for the user settings dropdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 11:52:06 -05:00
gsinghpal
e71bc503f9 changes 2026-02-25 09:40:41 -05:00
gsinghpal
0e1aebe60b feat: add Pending status for delivery/technician tasks
- New 'pending' status allows tasks to be created without a schedule,
  acting as a queue for unscheduled work that gets assigned later
- Pending group appears in the Delivery Map sidebar with amber color
- Other modules can create tasks in pending state for scheduling
- scheduled_date no longer required (null for pending tasks)
- New Pending Tasks menu item under Field Service
- Pending filter added to search view

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 04:21:05 -05:00
gsinghpal
84c009416e feat: add fusion_odoo_fixes module for default Odoo patches
- New standalone module to collect fixes for default Odoo behavior
- Fix #1: account_followup never clears followup_next_action_date
  when invoices are paid, causing collection emails to fully-paid
  clients. Hooks into _invoice_paid_hook to auto-clear stale data.
- Harden Fusion Accounting followup queries with amount_residual > 0
  filter and add balance check before sending emails

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 03:31:14 -05:00
393 changed files with 74870 additions and 4917 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
**/__pycache__/
*.pyc

View File

@@ -237,6 +237,7 @@ class FusionFollowupLine(models.Model):
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('amount_residual', '>', 0),
('date_maturity', '<', today),
])
line.overdue_amount = sum(overdue_lines.mapped('amount_residual'))
@@ -281,6 +282,8 @@ class FusionFollowupLine(models.Model):
of the current follow-up level, then advances the partner to
the next level.
Skips sending if the partner no longer has any overdue balance.
:raises UserError: If no follow-up level is set.
"""
self.ensure_one()
@@ -291,6 +294,11 @@ class FusionFollowupLine(models.Model):
self.partner_id.display_name,
))
self._compute_overdue_values()
if self.overdue_amount <= 0:
self.followup_status = 'no_action_needed'
return True
level = self.followup_level_id
partner = self.partner_id

View File

@@ -76,7 +76,8 @@ class FusionPartnerFollowup(models.Model):
"""Return unpaid receivable move lines that are past due.
Searches for posted, unreconciled journal items on receivable
accounts where the maturity date is earlier than today.
accounts where the maturity date is earlier than today and
there is still an outstanding balance.
:returns: An ``account.move.line`` recordset.
"""
@@ -88,6 +89,7 @@ class FusionPartnerFollowup(models.Model):
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('amount_residual', '>', 0),
('date_maturity', '<', today),
])

View File

@@ -188,6 +188,15 @@ class FusionFollowupSendWizard(models.TransientModel):
if not line:
raise UserError(_("No follow-up record is linked to this wizard."))
line._compute_overdue_values()
if line.overdue_amount <= 0:
line.followup_status = 'no_action_needed'
raise UserError(_(
"Partner '%s' no longer has any overdue balance. "
"Follow-up cancelled.",
line.partner_id.display_name,
))
partner = line.partner_id
# ---- Email ----

View File

@@ -2,3 +2,29 @@
from . import models
from . import controllers
def _reactivate_views(env):
"""Ensure all module views are active after install/update.
Odoo silently deactivates inherited views when an xpath fails
validation (e.g. parent view structure changed between versions).
Once deactivated, subsequent -u runs never reactivate them.
This hook prevents that from silently breaking the portal.
"""
views = env['ir.ui.view'].sudo().search([
('key', 'like', 'fusion_authorizer_portal.%'),
('active', '=', False),
])
if views:
views.write({'active': True})
env.cr.execute("""
SELECT key FROM ir_ui_view
WHERE key LIKE 'fusion_authorizer_portal.%%'
AND id = ANY(%s)
""", [views.ids])
keys = [r[0] for r in env.cr.fetchall()]
import logging
logging.getLogger(__name__).warning(
"Reactivated %d deactivated views: %s", len(keys), keys
)

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.2.0',
'version': '19.0.2.5.0',
'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """
@@ -50,8 +50,10 @@ This module provides external portal access for:
'website',
'mail',
'calendar',
'appointment',
'knowledge',
'fusion_claims',
'fusion_tasks',
],
'data': [
# Security
@@ -62,6 +64,7 @@ This module provides external portal access for:
'data/portal_menu_data.xml',
'data/ir_actions_server_data.xml',
'data/welcome_articles.xml',
'data/appointment_invite_data.xml',
# Views
'views/res_partner_views.xml',
'views/sale_order_views.xml',
@@ -76,7 +79,8 @@ This module provides external portal access for:
'views/portal_accessibility_forms.xml',
'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml',
'views/portal_repair_form.xml',
'views/portal_schedule.xml',
'views/portal_page11_sign_templates.xml',
],
'assets': {
'web.assets_backend': [
@@ -93,9 +97,11 @@ This module provides external portal access for:
'fusion_authorizer_portal/static/src/js/pdf_field_editor.js',
'fusion_authorizer_portal/static/src/js/technician_push.js',
'fusion_authorizer_portal/static/src/js/technician_location.js',
'fusion_authorizer_portal/static/src/js/portal_schedule_booking.js',
],
},
'images': ['static/description/icon.png'],
'post_init_hook': '_reactivate_views',
'installable': True,
'application': False,
'auto_install': False,

View File

@@ -3,4 +3,5 @@
from . import portal_main
from . import portal_assessment
from . import pdf_editor
from . import portal_repair
from . import portal_schedule
from . import portal_page11_sign

View File

@@ -26,6 +26,7 @@ class AuthorizerPortal(CustomerPortal):
if hasattr(response, 'qcontext') and (partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal):
posting_info = self._get_adp_posting_info()
response.qcontext.update(posting_info)
response.qcontext.update(self._get_clock_status_data())
# Add signature count (documents to sign) - only if Sign module is installed
sign_count = 0
@@ -724,7 +725,7 @@ class AuthorizerPortal(CustomerPortal):
'sale_type_filter': sale_type,
'status_filter': status,
}
values.update(self._get_clock_status_data())
return request.render('fusion_authorizer_portal.portal_sales_dashboard', values)
@http.route(['/my/sales/cases', '/my/sales/cases/page/<int:page>'], type='http', auth='user', website=True)
@@ -1090,14 +1091,60 @@ class AuthorizerPortal(CustomerPortal):
_logger.error(f"Error downloading proof of delivery: {e}")
return request.redirect('/my/funding-claims')
# ==================== CLOCK STATUS HELPER ====================
def _get_clock_status_data(self):
"""Get clock in/out status for the current portal user."""
try:
user = request.env.user
Employee = request.env['hr.employee'].sudo()
employee = Employee.search([('user_id', '=', user.id)], limit=1)
if not employee:
employee = Employee.search([
('name', '=', user.partner_id.name),
('user_id', '=', False),
], limit=1)
if not employee or not getattr(employee, 'x_fclk_enable_clock', False):
return {'clock_enabled': False}
is_checked_in = employee.attendance_state == 'checked_in'
check_in_time = ''
location_name = ''
if is_checked_in:
att = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),
('check_out', '=', False),
], limit=1)
if att:
check_in_time = att.check_in.isoformat() if att.check_in else ''
location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else ''
return {
'clock_enabled': True,
'clock_checked_in': is_checked_in,
'clock_check_in_time': check_in_time,
'clock_location_name': location_name,
}
except Exception as e:
_logger.warning("Clock status check failed: %s", e)
return {'clock_enabled': False}
# ==================== TECHNICIAN PORTAL ====================
def _check_technician_access(self):
"""Check if current user is a technician portal user."""
partner = request.env.user.partner_id
if not partner.is_technician_portal:
return False
if partner.is_technician_portal:
return True
has_tasks = request.env['fusion.technician.task'].sudo().search_count([
'|',
('technician_id', '=', request.env.user.id),
('additional_technician_ids', 'in', [request.env.user.id]),
], limit=1)
if has_tasks:
partner.sudo().write({'is_technician_portal': True})
return True
return False
@http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True)
def technician_dashboard(self, **kw):
@@ -1111,9 +1158,11 @@ class AuthorizerPortal(CustomerPortal):
SaleOrder = request.env['sale.order'].sudo()
today = fields.Date.context_today(request.env['fusion.technician.task'])
# Today's tasks
# Today's tasks (lead or additional technician)
today_tasks = Task.search([
'|',
('technician_id', '=', user.id),
('additional_technician_ids', 'in', [user.id]),
('scheduled_date', '=', today),
('status', '!=', 'cancelled'),
], order='sequence, time_start, id')
@@ -1143,7 +1192,9 @@ class AuthorizerPortal(CustomerPortal):
from datetime import timedelta
tomorrow = today + timedelta(days=1)
tomorrow_count = Task.search_count([
'|',
('technician_id', '=', user.id),
('additional_technician_ids', 'in', [user.id]),
('scheduled_date', '=', tomorrow),
('status', '!=', 'cancelled'),
])
@@ -1155,6 +1206,8 @@ class AuthorizerPortal(CustomerPortal):
ICP = request.env['ir.config_parameter'].sudo()
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
clock_data = self._get_clock_status_data()
values = {
'today_tasks': today_tasks,
'current_task': current_task,
@@ -1170,6 +1223,7 @@ class AuthorizerPortal(CustomerPortal):
'google_maps_api_key': google_maps_api_key,
'page_name': 'technician_dashboard',
}
values.update(clock_data)
return request.render('fusion_authorizer_portal.portal_technician_dashboard', values)
@http.route(['/my/technician/tasks', '/my/technician/tasks/page/<int:page>'], type='http', auth='user', website=True)
@@ -1181,7 +1235,7 @@ class AuthorizerPortal(CustomerPortal):
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
domain = [('technician_id', '=', user.id)]
domain = ['|', ('technician_id', '=', user.id), ('additional_technician_ids', 'in', [user.id])]
if filter_status == 'scheduled':
domain.append(('status', '=', 'scheduled'))
@@ -1237,14 +1291,19 @@ class AuthorizerPortal(CustomerPortal):
try:
task = Task.browse(task_id)
if not task.exists() or task.technician_id.id != user.id:
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
raise AccessError(_('You do not have access to this task.'))
except (AccessError, MissingError):
return request.redirect('/my/technician/tasks')
# Check for earlier uncompleted tasks (sequential enforcement)
earlier_incomplete = Task.search([
'|',
('technician_id', '=', user.id),
('additional_technician_ids', 'in', [user.id]),
('scheduled_date', '=', task.scheduled_date),
('time_start', '<', task.time_start),
('status', 'not in', ['completed', 'cancelled']),
@@ -1284,7 +1343,10 @@ class AuthorizerPortal(CustomerPortal):
Attachment = request.env['ir.attachment'].sudo()
try:
task = Task.browse(task_id)
if not task.exists() or task.technician_id.id != user.id:
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found'}
from markupsafe import Markup, escape
@@ -1411,34 +1473,61 @@ class AuthorizerPortal(CustomerPortal):
return {'success': False, 'error': str(e)}
@http.route('/my/technician/task/<int:task_id>/action', type='json', auth='user', website=True)
def technician_task_action(self, task_id, action, **kw):
"""Handle task status changes (start, complete, en_route, cancel)."""
def technician_task_action(self, task_id, action, latitude=None, longitude=None, accuracy=None, **kw):
"""Handle task status changes (start, complete, en_route, cancel).
Location is mandatory -- the client must send GPS coordinates."""
if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'}
if not latitude or not longitude:
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
return {'success': False, 'error': 'Invalid GPS coordinates.'}
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
try:
task = Task.browse(task_id)
if not task.exists() or task.technician_id.id != user.id:
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found or not assigned to you'}
request.env['fusion.technician.location'].sudo().log_location(
latitude=latitude,
longitude=longitude,
accuracy=accuracy,
)
# Push location to remote instances for cross-instance visibility
try:
request.env['fusion.task.sync.config'].sudo()._push_technician_location(
user.id, latitude, longitude, accuracy or 0)
except Exception:
pass # Non-blocking: sync failure should not block task action
location_ctx = {
'action_latitude': latitude,
'action_longitude': longitude,
'action_accuracy': accuracy or 0,
}
if action == 'en_route':
task.action_start_en_route()
task.with_context(**location_ctx).action_start_en_route()
elif action == 'start':
task.action_start_task()
task.with_context(**location_ctx).action_start_task()
elif action == 'complete':
completion_notes = kw.get('completion_notes', '')
if completion_notes:
task.completion_notes = completion_notes
task.action_complete_task()
task.with_context(**location_ctx).action_complete_task()
elif action == 'cancel':
task.action_cancel_task()
task.with_context(**location_ctx).action_cancel_task()
else:
return {'success': False, 'error': f'Unknown action: {action}'}
# For completion, also return next task info
result = {
'success': True,
'status': task.status,
@@ -1470,7 +1559,10 @@ class AuthorizerPortal(CustomerPortal):
ICP = request.env['ir.config_parameter'].sudo()
task = Task.browse(task_id)
if not task.exists() or task.technician_id.id != user.id:
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found'}
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
@@ -1532,7 +1624,10 @@ class AuthorizerPortal(CustomerPortal):
ICP = request.env['ir.config_parameter'].sudo()
task = Task.browse(task_id)
if not task.exists() or task.technician_id.id != user.id:
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found'}
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
@@ -1579,17 +1674,24 @@ class AuthorizerPortal(CustomerPortal):
return {'success': False, 'error': str(e)}
@http.route('/my/technician/task/<int:task_id>/voice-complete', type='json', auth='user', website=True)
def technician_voice_complete(self, task_id, transcription, **kw):
def technician_voice_complete(self, task_id, transcription, latitude=None, longitude=None, accuracy=None, **kw):
"""Format transcription with GPT and complete the task."""
if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'}
if not latitude or not longitude:
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
return {'success': False, 'error': 'Invalid GPS coordinates.'}
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
ICP = request.env['ir.config_parameter'].sudo()
task = Task.browse(task_id)
if not task.exists() or task.technician_id.id != user.id:
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found'}
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
@@ -1651,7 +1753,18 @@ class AuthorizerPortal(CustomerPortal):
'completion_notes': completion_html,
'voice_note_transcription': transcription,
})
task.action_complete_task()
request.env['fusion.technician.location'].sudo().log_location(
latitude=latitude,
longitude=longitude,
accuracy=accuracy,
)
location_ctx = {
'action_latitude': latitude,
'action_longitude': longitude,
'action_accuracy': accuracy or 0,
}
task.with_context(**location_ctx).action_complete_task()
return {
'success': True,
@@ -1672,7 +1785,9 @@ class AuthorizerPortal(CustomerPortal):
tomorrow = today + timedelta(days=1)
tomorrow_tasks = Task.search([
'|',
('technician_id', '=', user.id),
('additional_technician_ids', 'in', [user.id]),
('scheduled_date', '=', tomorrow),
('status', '!=', 'cancelled'),
], order='sequence, time_start, id')
@@ -1711,7 +1826,9 @@ class AuthorizerPortal(CustomerPortal):
return request.redirect('/my/technician')
tasks = Task.search([
'|',
('technician_id', '=', user.id),
('additional_technician_ids', 'in', [user.id]),
('scheduled_date', '=', schedule_date),
('status', '!=', 'cancelled'),
], order='sequence, time_start, id')
@@ -1760,6 +1877,25 @@ class AuthorizerPortal(CustomerPortal):
_logger.warning(f"Location log error: {e}")
return {'success': False}
@http.route('/my/technician/clock-status', type='json', auth='user', website=True)
def technician_clock_status(self, **kw):
"""Check if the current technician is clocked in.
Returns {clocked_in: bool} so the JS background logger can decide
whether to track location. Replaces the fixed 9-6 hour window.
"""
if not self._check_technician_access():
return {'clocked_in': False}
try:
emp = request.env['hr.employee'].sudo().search([
('user_id', '=', request.env.user.id),
], limit=1)
if emp and emp.attendance_state == 'checked_in':
return {'clocked_in': True}
except Exception:
pass
return {'clocked_in': False}
@http.route('/my/technician/settings/start-location', type='json', auth='user', website=True)
def technician_save_start_location(self, address='', **kw):
"""Save the technician's personal start location."""
@@ -1835,7 +1971,9 @@ class AuthorizerPortal(CustomerPortal):
if not has_access and partner.is_technician_portal:
task_count = request.env['fusion.technician.task'].sudo().search_count([
('sale_order_id', '=', order.id),
'|',
('technician_id', '=', user.id),
('additional_technician_ids', 'in', [user.id]),
])
if task_count:
has_access = True
@@ -2025,6 +2163,94 @@ class AuthorizerPortal(CustomerPortal):
_logger.error(f"Error saving POD signature: {e}")
return {'success': False, 'error': str(e)}
# ==================== TASK-LEVEL POD SIGNATURE ====================
@http.route('/my/technician/task/<int:task_id>/pod', type='http', auth='user', website=True)
def task_pod_signature_page(self, task_id, **kw):
"""Task-level POD signature capture page (works for all tasks including shadow)."""
if not self._check_technician_access():
return request.redirect('/my')
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
try:
task = Task.browse(task_id)
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
raise AccessError(_('You do not have access to this task.'))
except (AccessError, MissingError):
return request.redirect('/my/technician/tasks')
values = {
'task': task,
'has_existing_signature': bool(task.pod_signature),
'page_name': 'task_pod_signature',
}
return request.render('fusion_authorizer_portal.portal_task_pod_signature', values)
@http.route('/my/technician/task/<int:task_id>/pod/sign', type='json', auth='user', methods=['POST'])
def task_pod_save_signature(self, task_id, client_name, signature_data, signature_date=None, **kw):
"""Save POD signature directly on a task."""
if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'}
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
try:
task = Task.browse(task_id)
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found'}
if not client_name or not client_name.strip():
return {'success': False, 'error': 'Client name is required'}
if not signature_data:
return {'success': False, 'error': 'Signature is required'}
if ',' in signature_data:
signature_data = signature_data.split(',')[1]
from datetime import datetime as dt_datetime
sig_date = None
if signature_date:
try:
sig_date = dt_datetime.strptime(signature_date, '%Y-%m-%d').date()
except ValueError:
pass
task.write({
'pod_signature': signature_data,
'pod_client_name': client_name.strip(),
'pod_signature_date': sig_date,
'pod_signed_by_user_id': user.id,
'pod_signed_datetime': fields.Datetime.now(),
})
if task.sale_order_id:
task.sale_order_id.write({
'x_fc_pod_signature': signature_data,
'x_fc_pod_client_name': client_name.strip(),
'x_fc_pod_signature_date': sig_date,
'x_fc_pod_signed_by_user_id': user.id,
'x_fc_pod_signed_datetime': fields.Datetime.now(),
})
return {
'success': True,
'message': 'Signature saved successfully',
'redirect_url': f'/my/technician/task/{task_id}',
}
except Exception as e:
_logger.error(f"Error saving task POD signature: {e}")
return {'success': False, 'error': str(e)}
def _generate_signed_pod_pdf(self, order, save_to_field=True):
"""Generate a signed POD PDF with the signature embedded.
@@ -2474,3 +2700,71 @@ class AuthorizerPortal(CustomerPortal):
_logger.info(f"Attached video to assessment {assessment.reference}")
except Exception as e:
_logger.warning(f"Failed to attach video to assessment {assessment.reference}: {e}")
# =================================================================
# RENTAL PICKUP INSPECTION (added by fusion_rental)
# =================================================================
@http.route(
'/my/technician/rental-inspection/<int:task_id>',
type='http', auth='user', website=True,
)
def rental_inspection_page(self, task_id, **kw):
"""Render the rental pickup inspection form for the technician."""
user = request.env.user
task = request.env['fusion.technician.task'].sudo().browse(task_id)
if (
not task.exists()
or (task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids)
or task.task_type != 'pickup'
):
return request.redirect('/my')
return request.render(
'fusion_rental.portal_rental_inspection',
{
'task': task,
'order': task.sale_order_id,
'page_name': 'rental_inspection',
},
)
@http.route(
'/my/technician/rental-inspection/<int:task_id>/submit',
type='json', auth='user', methods=['POST'],
)
def rental_inspection_submit(self, task_id, **kwargs):
"""Save the rental inspection results."""
user = request.env.user
task = request.env['fusion.technician.task'].sudo().browse(task_id)
if (
not task.exists()
or (task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids)
or task.task_type != 'pickup'
):
return {'success': False, 'error': 'Access denied.'}
condition = kwargs.get('condition', '')
notes = kwargs.get('notes', '')
photo_ids = kwargs.get('photo_ids', [])
if not condition:
return {'success': False, 'error': 'Please select a condition.'}
vals = {
'rental_inspection_condition': condition,
'rental_inspection_notes': notes,
'rental_inspection_completed': True,
}
if photo_ids:
vals['rental_inspection_photo_ids'] = [(6, 0, photo_ids)]
task.write(vals)
return {
'success': True,
'message': 'Inspection saved. You can now complete the task.',
}

View File

@@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import json
import logging
from odoo import http, fields, _
from odoo.http import request
_logger = logging.getLogger(__name__)
class Page11PublicSignController(http.Controller):
def _get_sign_request(self, token):
"""Look up and validate a signing request by token."""
req = request.env['fusion.page11.sign.request'].sudo().search([
('access_token', '=', token),
], limit=1)
if not req:
return None, 'not_found'
if req.state == 'signed':
return req, 'already_signed'
if req.state == 'cancelled':
return req, 'cancelled'
if req.state == 'expired' or (
req.expiry_date and req.expiry_date < fields.Datetime.now()
):
if req.state != 'expired':
req.state = 'expired'
return req, 'expired'
return req, 'ok'
@http.route('/page11/sign/<string:token>', type='http', auth='public',
website=True, sitemap=False)
def page11_sign_form(self, token, **kw):
"""Display the Page 11 signing form."""
sign_req, status = self._get_sign_request(token)
if status == 'not_found':
return request.render(
'fusion_authorizer_portal.portal_page11_sign_invalid', {}
)
if status in ('expired', 'cancelled'):
return request.render(
'fusion_authorizer_portal.portal_page11_sign_expired',
{'sign_request': sign_req},
)
if status == 'already_signed':
return request.render(
'fusion_authorizer_portal.portal_page11_sign_success',
{'sign_request': sign_req, 'token': token},
)
order = sign_req.sale_order_id
partner = order.partner_id
assessment = request.env['fusion.assessment'].sudo().search([
('sale_order_id', '=', order.id),
], limit=1, order='create_date desc')
ICP = request.env['ir.config_parameter'].sudo()
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
client_first_name = ''
client_last_name = ''
client_middle_name = ''
client_health_card = ''
client_health_card_version = ''
if assessment:
client_first_name = assessment.client_first_name or ''
client_last_name = assessment.client_last_name or ''
client_middle_name = assessment.client_middle_name or ''
client_health_card = assessment.client_health_card or ''
client_health_card_version = assessment.client_health_card_version or ''
else:
first, last = order._get_client_name_parts()
client_first_name = first
client_last_name = last
values = {
'sign_request': sign_req,
'order': order,
'partner': partner,
'assessment': assessment,
'company': order.company_id,
'token': token,
'signer_type': sign_req.signer_type,
'is_agent': sign_req.signer_type != 'client',
'google_maps_api_key': google_maps_api_key,
'client_first_name': client_first_name,
'client_last_name': client_last_name,
'client_middle_name': client_middle_name,
'client_health_card': client_health_card,
'client_health_card_version': client_health_card_version,
}
return request.render(
'fusion_authorizer_portal.portal_page11_public_sign', values,
)
@http.route('/page11/sign/<string:token>/submit', type='http',
auth='public', methods=['POST'], website=True,
csrf=True, sitemap=False)
def page11_sign_submit(self, token, **post):
"""Process the submitted Page 11 signature."""
sign_req, status = self._get_sign_request(token)
if status != 'ok':
return request.redirect(f'/page11/sign/{token}')
signature_data = post.get('signature_data', '')
if not signature_data:
return request.redirect(f'/page11/sign/{token}?error=no_signature')
if signature_data.startswith('data:image'):
signature_data = signature_data.split(',', 1)[1]
consent_accepted = post.get('consent_declaration', '') == 'on'
if not consent_accepted:
return request.redirect(f'/page11/sign/{token}?error=no_consent')
signer_name = post.get('signer_name', sign_req.signer_name or '')
chosen_signer_type = post.get('signer_type', sign_req.signer_type or 'client')
consent_signed_by = 'applicant' if chosen_signer_type == 'client' else 'agent'
signer_type_labels = {
'spouse': 'Spouse', 'parent': 'Parent',
'legal_guardian': 'Legal Guardian',
'poa': 'Power of Attorney',
'public_trustee': 'Public Trustee',
}
vals = {
'signature_data': signature_data,
'signer_name': signer_name,
'signer_type': chosen_signer_type,
'consent_declaration_accepted': True,
'consent_signed_by': consent_signed_by,
'signed_date': fields.Datetime.now(),
'state': 'signed',
'client_first_name': post.get('client_first_name', ''),
'client_last_name': post.get('client_last_name', ''),
'client_health_card': post.get('client_health_card', ''),
'client_health_card_version': post.get('client_health_card_version', ''),
}
if consent_signed_by == 'agent':
vals.update({
'agent_first_name': post.get('agent_first_name', ''),
'agent_last_name': post.get('agent_last_name', ''),
'agent_middle_initial': post.get('agent_middle_initial', ''),
'agent_phone': post.get('agent_phone', ''),
'agent_unit': post.get('agent_unit', ''),
'agent_street_number': post.get('agent_street_number', ''),
'agent_street': post.get('agent_street', ''),
'agent_city': post.get('agent_city', ''),
'agent_province': post.get('agent_province', 'Ontario'),
'agent_postal_code': post.get('agent_postal_code', ''),
'signer_relationship': signer_type_labels.get(chosen_signer_type, chosen_signer_type),
})
sign_req.sudo().write(vals)
try:
sign_req.sudo()._generate_signed_pdf()
except Exception as e:
_logger.error("PDF generation failed for sign request %s: %s", sign_req.id, e)
try:
sign_req.sudo()._update_sale_order()
except Exception as e:
_logger.error("Sale order update failed for sign request %s: %s", sign_req.id, e)
return request.render(
'fusion_authorizer_portal.portal_page11_sign_success',
{'sign_request': sign_req, 'token': token},
)
@http.route('/page11/sign/<string:token>/download', type='http',
auth='public', website=True, sitemap=False)
def page11_download_pdf(self, token, **kw):
"""Download the signed Page 11 PDF."""
sign_req = request.env['fusion.page11.sign.request'].sudo().search([
('access_token', '=', token),
('state', '=', 'signed'),
], limit=1)
if not sign_req or not sign_req.signed_pdf:
return request.redirect(f'/page11/sign/{token}')
pdf_content = base64.b64decode(sign_req.signed_pdf)
filename = sign_req.signed_pdf_filename or 'Page11_Signed.pdf'
return request.make_response(
pdf_content,
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', f'attachment; filename="{filename}"'),
('Content-Length', str(len(pdf_content))),
],
)

View File

@@ -0,0 +1,327 @@
# -*- coding: utf-8 -*-
from odoo import http, _, fields
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.exceptions import AccessError, ValidationError
from datetime import datetime, timedelta
import json
import logging
import pytz
_logger = logging.getLogger(__name__)
class PortalSchedule(CustomerPortal):
"""Portal controller for appointment scheduling and calendar management."""
def _get_schedule_values(self):
"""Common values for schedule pages."""
ICP = request.env['ir.config_parameter'].sudo()
g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848')
g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7')
g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad')
gradient = 'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end)
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
return {
'portal_gradient': gradient,
'google_maps_api_key': google_maps_api_key,
}
def _get_user_timezone(self):
tz_name = request.env.user.tz or 'America/Toronto'
try:
return pytz.timezone(tz_name)
except pytz.exceptions.UnknownTimeZoneError:
return pytz.timezone('America/Toronto')
def _get_appointment_types(self):
"""Get appointment types available to the current user."""
return request.env['appointment.type'].sudo().search([
('staff_user_ids', 'in', [request.env.user.id]),
])
@http.route(['/my/schedule'], type='http', auth='user', website=True)
def schedule_page(self, **kw):
"""Schedule overview: upcoming appointments and shareable link."""
partner = request.env.user.partner_id
user = request.env.user
now = fields.Datetime.now()
upcoming_events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', now),
], order='start asc', limit=20)
today_events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', now.replace(hour=0, minute=0, second=0)),
('start', '<', (now + timedelta(days=1)).replace(hour=0, minute=0, second=0)),
], order='start asc')
invite = request.env['appointment.invite'].sudo().search([
('staff_user_ids', 'in', [user.id]),
], limit=1)
share_url = invite.book_url if invite else ''
appointment_types = self._get_appointment_types()
tz = self._get_user_timezone()
values = self._get_schedule_values()
values.update({
'page_name': 'schedule',
'upcoming_events': upcoming_events,
'today_events': today_events,
'share_url': share_url,
'appointment_types': appointment_types,
'user_tz': tz,
'now': now,
})
return request.render('fusion_authorizer_portal.portal_schedule_page', values)
@http.route(['/my/schedule/book'], type='http', auth='user', website=True)
def schedule_book(self, appointment_type_id=None, **kw):
"""Booking form for a new appointment."""
appointment_types = self._get_appointment_types()
if not appointment_types:
return request.redirect('/my/schedule')
if appointment_type_id:
selected_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
if not selected_type.exists():
selected_type = appointment_types[0]
else:
selected_type = appointment_types[0]
values = self._get_schedule_values()
values.update({
'page_name': 'schedule_book',
'appointment_types': appointment_types,
'selected_type': selected_type,
'now': fields.Datetime.now(),
'error': kw.get('error'),
'success': kw.get('success'),
})
return request.render('fusion_authorizer_portal.portal_schedule_book', values)
@http.route('/my/schedule/available-slots', type='json', auth='user', website=True)
def schedule_available_slots(self, appointment_type_id, selected_date=None, **kw):
"""JSON-RPC endpoint: return available time slots for a date."""
appointment_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
if not appointment_type.exists():
return {'error': 'Appointment type not found', 'slots': []}
user = request.env.user
tz_name = user.tz or 'America/Toronto'
tz = self._get_user_timezone()
ref_date = fields.Datetime.now()
slot_data = appointment_type._get_appointment_slots(
timezone=tz_name,
filter_users=request.env['res.users'].sudo().browse(user.id),
asked_capacity=1,
reference_date=ref_date,
)
filtered_slots = []
target_date = None
if selected_date:
try:
target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
except ValueError:
return {'error': 'Invalid date format', 'slots': []}
for month_data in slot_data:
for week in month_data.get('weeks', []):
for day_info in week:
if not day_info:
continue
day = day_info.get('day')
if target_date and day != target_date:
continue
for slot in day_info.get('slots', []):
slot_dt_str = slot.get('datetime')
if not slot_dt_str:
continue
filtered_slots.append({
'datetime': slot_dt_str,
'start_hour': slot.get('start_hour', ''),
'end_hour': slot.get('end_hour', ''),
'duration': slot.get('slot_duration', str(appointment_type.appointment_duration)),
'staff_user_id': slot.get('staff_user_id', user.id),
})
available_dates = []
if not target_date:
seen = set()
for month_data in slot_data:
for week in month_data.get('weeks', []):
for day_info in week:
if not day_info:
continue
day = day_info.get('day')
if day and day_info.get('slots') and str(day) not in seen:
seen.add(str(day))
available_dates.append(str(day))
return {
'slots': filtered_slots,
'available_dates': sorted(available_dates),
'duration': appointment_type.appointment_duration,
'timezone': tz_name,
}
@http.route('/my/schedule/week-events', type='json', auth='user', website=True)
def schedule_week_events(self, selected_date, **kw):
"""Return the user's calendar events for the Mon-Sun week containing selected_date."""
try:
target = datetime.strptime(selected_date, '%Y-%m-%d').date()
except (ValueError, TypeError):
return {'error': 'Invalid date format', 'events': [], 'week_days': []}
monday = target - timedelta(days=target.weekday())
sunday = monday + timedelta(days=6)
partner = request.env.user.partner_id
tz = self._get_user_timezone()
monday_start_local = tz.localize(datetime.combine(monday, datetime.min.time()))
sunday_end_local = tz.localize(datetime.combine(sunday, datetime.max.time()))
monday_start_utc = monday_start_local.astimezone(pytz.utc).replace(tzinfo=None)
sunday_end_utc = sunday_end_local.astimezone(pytz.utc).replace(tzinfo=None)
events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', monday_start_utc),
('start', '<=', sunday_end_utc),
], order='start asc')
event_list = []
for ev in events:
start_utc = ev.start
stop_utc = ev.stop
start_local = pytz.utc.localize(start_utc).astimezone(tz)
stop_local = pytz.utc.localize(stop_utc).astimezone(tz)
event_list.append({
'name': ev.name or '',
'start': start_local.strftime('%Y-%m-%d %H:%M'),
'end': stop_local.strftime('%Y-%m-%d %H:%M'),
'start_time': start_local.strftime('%I:%M %p'),
'end_time': stop_local.strftime('%I:%M %p'),
'day_of_week': start_local.weekday(),
'date': start_local.strftime('%Y-%m-%d'),
'location': ev.location or '',
'duration': ev.duration,
})
day_labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
week_days = []
for i in range(7):
day = monday + timedelta(days=i)
week_days.append({
'label': day_labels[i],
'date': day.strftime('%Y-%m-%d'),
'day_num': day.day,
'is_selected': day == target,
})
return {
'events': event_list,
'week_days': week_days,
'selected_date': selected_date,
}
@http.route('/my/schedule/book/submit', type='http', auth='user', website=True, methods=['POST'])
def schedule_book_submit(self, **post):
"""Process the booking form submission."""
appointment_type_id = int(post.get('appointment_type_id', 0))
appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id)
if not appointment_type.exists():
return request.redirect('/my/schedule/book?error=Invalid+appointment+type')
client_name = (post.get('client_name') or '').strip()
client_street = (post.get('client_street') or '').strip()
client_city = (post.get('client_city') or '').strip()
client_province = (post.get('client_province') or '').strip()
client_postal = (post.get('client_postal') or '').strip()
notes = (post.get('notes') or '').strip()
slot_datetime = (post.get('slot_datetime') or '').strip()
slot_duration = post.get('slot_duration', str(appointment_type.appointment_duration))
if not client_name or not slot_datetime:
return request.redirect('/my/schedule/book?error=Client+name+and+time+slot+are+required')
user = request.env.user
tz = self._get_user_timezone()
try:
start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
start_dt_local = tz.localize(start_dt_naive)
start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception) as e:
_logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
return request.redirect('/my/schedule/book?error=Invalid+time+slot')
duration = float(slot_duration)
stop_dt_utc = start_dt_utc + timedelta(hours=duration)
is_valid = appointment_type._check_appointment_is_valid_slot(
staff_user=user,
resources=request.env['appointment.resource'],
asked_capacity=1,
timezone=str(tz),
start_dt=start_dt_utc,
duration=duration,
allday=False,
)
if not is_valid:
return request.redirect('/my/schedule/book?error=This+slot+is+no+longer+available.+Please+choose+another+time.')
address_parts = [p for p in [client_street, client_city, client_province, client_postal] if p]
location = ', '.join(address_parts)
description_lines = []
if client_name:
description_lines.append(f"Client: {client_name}")
if location:
description_lines.append(f"Address: {location}")
if notes:
description_lines.append(f"Notes: {notes}")
description = '\n'.join(description_lines)
event_name = f"{client_name} - {appointment_type.name}"
booking_line_values = [{
'appointment_user_id': user.id,
'capacity_reserved': 1,
'capacity_used': 1,
}]
try:
event_vals = appointment_type._prepare_calendar_event_values(
asked_capacity=1,
booking_line_values=booking_line_values,
description=description,
duration=duration,
allday=False,
appointment_invite=request.env['appointment.invite'],
guests=request.env['res.partner'],
name=event_name,
customer=user.partner_id,
staff_user=user,
start=start_dt_utc,
stop=stop_dt_utc,
)
event_vals['location'] = location
event = request.env['calendar.event'].sudo().create(event_vals)
_logger.info(
"Appointment booked: %s at %s (event ID: %s)",
event_name, start_dt_utc, event.id,
)
except Exception as e:
_logger.error("Failed to create appointment: %s", e)
return request.redirect('/my/schedule/book?error=Failed+to+create+appointment.+Please+try+again.')
return request.redirect('/my/schedule?success=Appointment+booked+successfully')

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Auto-create a shareable booking link for staff members.
URL: /book/book-appointment
Filtered to appointment type "Assessment" and staff users configured on that type. -->
<record id="default_appointment_invite" model="appointment.invite">
<field name="short_code">book-appointment</field>
<field name="appointment_type_ids" eval="[(6, 0, [])]"/>
</record>
</odoo>

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""Reactivate any views that Odoo silently deactivated.
Odoo deactivates inherited views when xpath validation fails (e.g. parent
view structure changed between versions). Once deactivated, subsequent
``-u`` runs never reactivate them. This end-migration script catches
that scenario on every version bump.
"""
import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
def migrate(cr, version):
if not version:
return
cr.execute("""
UPDATE ir_ui_view v
SET active = true
FROM ir_model_data d
WHERE d.res_id = v.id
AND d.model = 'ir.ui.view'
AND d.module = %s
AND v.active = false
RETURNING v.id, v.name, v.key
""", [MODULE])
rows = cr.fetchall()
if rows:
_logger.warning(
"Reactivated %d deactivated views for %s: %s",
len(rows), MODULE, [r[2] or r[1] for r in rows],
)

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""Reactivate any views that Odoo silently deactivated.
Odoo deactivates inherited views when xpath validation fails (e.g. parent
view structure changed between versions). Once deactivated, subsequent
``-u`` runs never reactivate them. This end-migration script catches
that scenario on every version bump.
"""
import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
def migrate(cr, version):
if not version:
return
cr.execute("""
UPDATE ir_ui_view v
SET active = true
FROM ir_model_data d
WHERE d.res_id = v.id
AND d.model = 'ir.ui.view'
AND d.module = %s
AND v.active = false
RETURNING v.id, v.name, v.key
""", [MODULE])
rows = cr.fetchall()
if rows:
_logger.warning(
"Reactivated %d deactivated views for %s: %s",
len(rows), MODULE, [r[2] or r[1] for r in rows],
)

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""Reactivate any views that Odoo silently deactivated.
Odoo deactivates inherited views when xpath validation fails (e.g. parent
view structure changed between versions). Once deactivated, subsequent
``-u`` runs never reactivate them. This end-migration script catches
that scenario on every version bump.
"""
import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
def migrate(cr, version):
if not version:
return
cr.execute("""
UPDATE ir_ui_view v
SET active = true
FROM ir_model_data d
WHERE d.res_id = v.id
AND d.model = 'ir.ui.view'
AND d.module = %s
AND v.active = false
RETURNING v.id, v.name, v.key
""", [MODULE])
rows = cr.fetchall()
if rows:
_logger.warning(
"Reactivated %d deactivated views for %s: %s",
len(rows), MODULE, [r[2] or r[1] for r in rows],
)

View File

@@ -499,6 +499,7 @@ class FusionAssessment(models.Model):
'res_model': 'sale.order',
'res_id': sale_order.id,
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'current',
}
@@ -1482,6 +1483,7 @@ class FusionAssessment(models.Model):
'name': _('Documents'),
'res_model': 'fusion.adp.document',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('assessment_id', '=', self.id)],
'context': {'default_assessment_id': self.id},
}
@@ -1497,6 +1499,7 @@ class FusionAssessment(models.Model):
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'current',
}

View File

@@ -23,5 +23,6 @@ class FusionLoanerCheckoutAssessment(models.Model):
'type': 'ir.actions.act_window',
'res_model': 'fusion.assessment',
'view_mode': 'form',
'views': [(False, 'form')],
'res_id': self.assessment_id.id,
}

View File

@@ -160,7 +160,7 @@ class ResPartner(models.Model):
if self.is_technician_portal:
# Add Field Technician group
g = self.env.ref('fusion_claims.group_field_technician', raise_if_not_found=False)
g = self.env.ref('fusion_tasks.group_field_technician', raise_if_not_found=False)
if g and g not in internal_user.group_ids:
internal_user.sudo().write({'group_ids': [(4, g.id)]})
added.append('Field Technician')
@@ -596,6 +596,7 @@ class ResPartner(models.Model):
'name': _('Assigned Cases'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('x_fc_authorizer_id', '=', self.id)],
'context': {'default_x_fc_authorizer_id': self.id},
}
@@ -614,6 +615,7 @@ class ResPartner(models.Model):
'name': _('Assessments'),
'res_model': 'fusion.assessment',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': domain,
}
@@ -697,6 +699,7 @@ class ResPartner(models.Model):
'name': _('Assigned Deliveries'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('x_fc_delivery_technician_ids', 'in', [self.authorizer_portal_user_id.id])],
}

View File

@@ -101,6 +101,7 @@ class SaleOrder(models.Model):
'name': 'Message Authorizer',
'res_model': 'mail.compose.message',
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'new',
'context': {
'default_model': 'sale.order',
@@ -137,6 +138,7 @@ class SaleOrder(models.Model):
'name': _('Portal Comments'),
'res_model': 'fusion.authorizer.comment',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
@@ -149,6 +151,7 @@ class SaleOrder(models.Model):
'name': _('Portal Documents'),
'res_model': 'fusion.adp.document',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}

View File

@@ -1,22 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Portal Groups - grouped under Fusion Claims privilege -->
<!-- Portal Groups (auto-assigned from Contact form Portal Access tab) -->
<record id="group_authorizer_portal" model="res.groups">
<field name="name">Authorizer Portal User</field>
<field name="privilege_id" ref="fusion_claims.res_groups_privilege_fusion_claims"/>
<field name="privilege_id" eval="False"/>
<field name="comment">Portal users who are Authorizers (OTs/Therapists)</field>
</record>
<record id="group_sales_rep_portal" model="res.groups">
<field name="name">Sales Rep Portal User</field>
<field name="privilege_id" ref="fusion_claims.res_groups_privilege_fusion_claims"/>
<field name="privilege_id" eval="False"/>
<field name="comment">Portal users who are Sales Representatives</field>
</record>
<record id="group_technician_portal" model="res.groups">
<field name="name">Technician Portal User</field>
<field name="privilege_id" ref="fusion_claims.res_groups_privilege_fusion_claims"/>
<field name="privilege_id" eval="False"/>
<field name="comment">Portal users who are Field Technicians for deliveries</field>
</record>

View File

@@ -14,16 +14,12 @@
.tech-stats-bar {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scrollbar-width: none;
}
.tech-stats-bar::-webkit-scrollbar { display: none; }
.tech-stat-card {
flex: 0 0 auto;
min-width: 100px;
padding: 0.75rem 1rem;
flex: 1 1 0;
min-width: 0;
padding: 0.75rem 0.5rem;
border-radius: 12px;
text-align: center;
color: #fff;
@@ -42,7 +38,145 @@
.tech-stat-total { background: linear-gradient(135deg, #5ba848, #3a8fb7); }
.tech-stat-remaining { background: linear-gradient(135deg, #3498db, #2980b9); }
.tech-stat-completed { background: linear-gradient(135deg, #27ae60, #219a52); }
.tech-stat-travel { background: linear-gradient(135deg, #8e44ad, #7d3c98); }
/* ---- Clock In/Out Card ---- */
.tech-clock-card {
background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e9ecef);
border-radius: 14px;
padding: 0.875rem 1rem;
}
.tech-clock-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #adb5bd;
flex-shrink: 0;
}
.tech-clock-dot--active {
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
animation: tech-clock-pulse 2s ease-in-out infinite;
}
@keyframes tech-clock-pulse {
0%, 100% { box-shadow: 0 0 6px rgba(16, 185, 129, 0.5); }
50% { box-shadow: 0 0 12px rgba(16, 185, 129, 0.8); }
}
.tech-clock-status {
font-size: 0.85rem;
font-weight: 600;
color: var(--o-main-text-color, #212529);
line-height: 1.2;
}
.tech-clock-timer {
font-size: 0.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: #6c757d;
}
.tech-clock-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: 10px;
border: none;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.tech-clock-btn:active { transform: scale(0.96); }
.tech-clock-btn--in {
background: #10b981;
color: #fff;
}
.tech-clock-btn--in:hover { background: #059669; }
.tech-clock-btn--out {
background: #ef4444;
color: #fff;
}
.tech-clock-btn--out:hover { background: #dc2626; }
.tech-clock-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tech-clock-error {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
padding: 0.4rem 0.75rem;
border-radius: 8px;
background: #fef2f2;
color: #dc2626;
font-size: 0.8rem;
font-weight: 500;
}
/* ---- Quick Links (All Tasks / Tomorrow / Repair Form) ---- */
.tech-quick-links {
display: flex;
gap: 0.5rem;
}
.tech-quick-link {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.875rem 0.5rem;
border-radius: 12px;
border: 1.5px solid;
text-decoration: none !important;
font-weight: 600;
font-size: 0.8rem;
transition: all 0.15s;
position: relative;
}
.tech-quick-link:active { transform: scale(0.97); }
.tech-quick-link i { font-size: 1.1rem; }
.tech-quick-link-primary {
border-color: #3498db;
color: #3498db !important;
background: rgba(52, 152, 219, 0.04);
}
.tech-quick-link-primary:hover { background: rgba(52, 152, 219, 0.1); }
.tech-quick-link-secondary {
border-color: #6c757d;
color: #6c757d !important;
background: rgba(108, 117, 125, 0.04);
}
.tech-quick-link-secondary:hover { background: rgba(108, 117, 125, 0.1); }
.tech-quick-link-warning {
border-color: #e67e22;
color: #e67e22 !important;
background: rgba(230, 126, 34, 0.04);
}
.tech-quick-link-warning:hover { background: rgba(230, 126, 34, 0.1); }
.tech-quick-link-badge {
position: absolute;
top: -6px;
right: -6px;
background: #3498db;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 9px;
padding: 0 4px;
}
/* ---- Hero Card (Dashboard Current Task) ---- */
.tech-hero-card {
@@ -475,12 +609,18 @@
gap: 1rem;
}
.tech-stat-card {
min-width: 130px;
padding: 1rem 1.5rem;
}
.tech-stat-card .stat-number {
font-size: 2rem;
}
.tech-quick-links {
gap: 1rem;
}
.tech-quick-link {
padding: 1rem 0.75rem;
font-size: 0.85rem;
}
.tech-bottom-bar {
position: static;
box-shadow: none;

View File

@@ -28,6 +28,9 @@ patch(Chatter.prototype, {
[thread.id],
);
if (result && result.type === "ir.actions.act_window") {
if (!result.views && result.view_mode) {
result.views = result.view_mode.split(",").map(v => [false, v.trim()]);
}
this._fapActionService.doAction(result);
}
} catch (e) {

View File

@@ -0,0 +1,343 @@
(function () {
'use strict';
var dateInput = document.getElementById('bookingDate');
var slotsContainer = document.getElementById('slotsContainer');
var slotsGrid = document.getElementById('slotsGrid');
var slotsLoading = document.getElementById('slotsLoading');
var noSlots = document.getElementById('noSlots');
var slotDatetimeInput = document.getElementById('slotDatetime');
var slotDurationInput = document.getElementById('slotDuration');
var submitBtn = document.getElementById('btnSubmitBooking');
var typeSelect = document.getElementById('appointmentTypeSelect');
var selectedSlotBtn = null;
var weekContainer = document.getElementById('weekCalendarContainer');
var weekLoading = document.getElementById('weekCalendarLoading');
var weekGrid = document.getElementById('weekCalendarGrid');
var weekHeader = document.getElementById('weekCalendarHeader');
var weekBody = document.getElementById('weekCalendarBody');
var weekEmpty = document.getElementById('weekCalendarEmpty');
function getAppointmentTypeId() {
if (typeSelect) return typeSelect.value;
var hidden = document.querySelector('input[name="appointment_type_id"]');
return hidden ? hidden.value : null;
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function truncate(str, max) {
if (!str) return '';
return str.length > max ? str.substring(0, max) + '...' : str;
}
function fetchWeekEvents(date) {
if (!weekContainer || !date) return;
weekContainer.style.display = 'block';
weekLoading.style.display = 'block';
weekGrid.style.display = 'none';
weekEmpty.style.display = 'none';
fetch('/my/schedule/week-events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: { selected_date: date },
}),
})
.then(function (resp) { return resp.json(); })
.then(function (data) {
weekLoading.style.display = 'none';
var result = data.result || {};
var events = result.events || [];
var weekDays = result.week_days || [];
if (result.error || !weekDays.length) {
weekEmpty.style.display = 'block';
return;
}
renderWeekCalendar(weekDays, events, date);
})
.catch(function () {
weekLoading.style.display = 'none';
weekEmpty.textContent = 'Failed to load calendar. Please try again.';
weekEmpty.style.display = 'block';
});
}
function renderWeekCalendar(weekDays, events, selectedDate) {
weekHeader.innerHTML = '';
weekBody.innerHTML = '';
var eventsByDate = {};
events.forEach(function (ev) {
if (!eventsByDate[ev.date]) eventsByDate[ev.date] = [];
eventsByDate[ev.date].push(ev);
});
var hasAnyEvents = events.length > 0;
weekDays.forEach(function (day) {
var isSelected = day.date === selectedDate;
var isWeekend = day.label === 'Sat' || day.label === 'Sun';
var dayEvents = eventsByDate[day.date] || [];
var headerCell = document.createElement('div');
headerCell.className = 'text-center py-2 flex-fill';
headerCell.style.cssText = 'min-width: 0; font-size: 12px; border-right: 1px solid #dee2e6;';
if (isSelected) {
headerCell.style.backgroundColor = '#e8f4fd';
}
if (isWeekend) {
headerCell.style.opacity = '0.6';
}
var labelEl = document.createElement('div');
labelEl.className = 'fw-semibold text-muted';
labelEl.textContent = day.label;
var numEl = document.createElement('div');
numEl.className = isSelected ? 'fw-bold text-primary' : 'fw-semibold';
numEl.style.fontSize = '14px';
numEl.textContent = day.day_num;
headerCell.appendChild(labelEl);
headerCell.appendChild(numEl);
weekHeader.appendChild(headerCell);
var bodyCell = document.createElement('div');
bodyCell.className = 'flex-fill p-1';
bodyCell.style.cssText = 'min-width: 0; min-height: 70px; border-right: 1px solid #dee2e6; overflow: hidden;';
if (isSelected) {
bodyCell.style.backgroundColor = '#f0f8ff';
}
if (dayEvents.length) {
dayEvents.forEach(function (ev) {
var card = document.createElement('div');
card.className = 'mb-1 px-1 py-1 rounded';
card.style.cssText = 'font-size: 11px; background: #eef6ff; border-left: 3px solid #3a8fb7; overflow: hidden; cursor: default;';
card.title = ev.start_time + ' - ' + ev.end_time + '\n' + ev.name + (ev.location ? '\n' + ev.location : '');
var timeEl = document.createElement('div');
timeEl.className = 'fw-semibold text-primary';
timeEl.style.fontSize = '10px';
timeEl.textContent = ev.start_time;
var nameEl = document.createElement('div');
nameEl.className = 'text-truncate';
nameEl.style.fontSize = '10px';
nameEl.textContent = truncate(ev.name, 18);
card.appendChild(timeEl);
card.appendChild(nameEl);
bodyCell.appendChild(card);
});
}
weekBody.appendChild(bodyCell);
});
if (hasAnyEvents) {
weekGrid.style.display = 'block';
weekEmpty.style.display = 'none';
} else {
weekGrid.style.display = 'none';
weekEmpty.style.display = 'block';
}
}
function fetchSlots(date) {
var typeId = getAppointmentTypeId();
if (!typeId || !date) return;
slotsContainer.style.display = 'block';
slotsLoading.style.display = 'block';
slotsGrid.innerHTML = '';
noSlots.style.display = 'none';
slotDatetimeInput.value = '';
if (submitBtn) submitBtn.disabled = true;
selectedSlotBtn = null;
fetch('/my/schedule/available-slots', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
appointment_type_id: parseInt(typeId),
selected_date: date,
},
}),
})
.then(function (resp) { return resp.json(); })
.then(function (data) {
slotsLoading.style.display = 'none';
var result = data.result || {};
var slots = result.slots || [];
if (result.error) {
noSlots.textContent = result.error;
noSlots.style.display = 'block';
return;
}
if (!slots.length) {
noSlots.style.display = 'block';
return;
}
var morningSlots = [];
var afternoonSlots = [];
slots.forEach(function (slot) {
var hour = parseInt(slot.start_hour);
if (isNaN(hour)) {
var match = slot.start_hour.match(/(\d+)/);
hour = match ? parseInt(match[1]) : 0;
if (slot.start_hour.toLowerCase().indexOf('pm') > -1 && hour !== 12) hour += 12;
if (slot.start_hour.toLowerCase().indexOf('am') > -1 && hour === 12) hour = 0;
}
if (hour < 12) {
morningSlots.push(slot);
} else {
afternoonSlots.push(slot);
}
});
function renderGroup(label, icon, groupSlots) {
if (!groupSlots.length) return;
var header = document.createElement('div');
header.className = 'w-100 mt-2 mb-1';
header.innerHTML = '<small class="text-muted fw-semibold"><i class="fa ' + icon + ' me-1"></i>' + label + '</small>';
slotsGrid.appendChild(header);
groupSlots.forEach(function (slot) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-outline-primary btn-sm slot-btn';
btn.style.cssText = 'min-width: 100px; border-radius: 8px; padding: 8px 14px;';
btn.textContent = slot.start_hour;
btn.dataset.datetime = slot.datetime;
btn.dataset.duration = slot.duration;
btn.addEventListener('click', function () {
if (selectedSlotBtn) {
selectedSlotBtn.classList.remove('btn-primary');
selectedSlotBtn.classList.add('btn-outline-primary');
}
btn.classList.remove('btn-outline-primary');
btn.classList.add('btn-primary');
selectedSlotBtn = btn;
slotDatetimeInput.value = slot.datetime;
slotDurationInput.value = slot.duration;
if (submitBtn) submitBtn.disabled = false;
});
slotsGrid.appendChild(btn);
});
}
renderGroup('Morning', 'fa-sun-o', morningSlots);
renderGroup('Afternoon', 'fa-cloud', afternoonSlots);
})
.catch(function (err) {
slotsLoading.style.display = 'none';
noSlots.textContent = 'Failed to load slots. Please try again.';
noSlots.style.display = 'block';
});
}
if (dateInput) {
dateInput.addEventListener('change', function () {
var val = this.value;
fetchWeekEvents(val);
fetchSlots(val);
});
}
if (typeSelect) {
typeSelect.addEventListener('change', function () {
if (dateInput && dateInput.value) {
fetchSlots(dateInput.value);
}
});
}
var bookingForm = document.getElementById('bookingForm');
if (bookingForm) {
bookingForm.addEventListener('submit', function (e) {
if (!slotDatetimeInput || !slotDatetimeInput.value) {
e.preventDefault();
alert('Please select a time slot before booking.');
return false;
}
var clientName = bookingForm.querySelector('input[name="client_name"]');
if (!clientName || !clientName.value.trim()) {
e.preventDefault();
alert('Please enter the client name.');
return false;
}
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Booking...';
}
});
}
window.initScheduleAddressAutocomplete = function () {
var streetInput = document.getElementById('clientStreet');
if (!streetInput) return;
var autocomplete = new google.maps.places.Autocomplete(streetInput, {
componentRestrictions: { country: 'ca' },
types: ['address'],
});
autocomplete.addListener('place_changed', function () {
var place = autocomplete.getPlace();
if (!place.address_components) return;
var streetNumber = '';
var streetName = '';
var city = '';
var province = '';
var postalCode = '';
for (var i = 0; i < place.address_components.length; i++) {
var component = place.address_components[i];
var types = component.types;
if (types.indexOf('street_number') > -1) {
streetNumber = component.long_name;
} else if (types.indexOf('route') > -1) {
streetName = component.long_name;
} else if (types.indexOf('locality') > -1) {
city = component.long_name;
} else if (types.indexOf('administrative_area_level_1') > -1) {
province = component.long_name;
} else if (types.indexOf('postal_code') > -1) {
postalCode = component.long_name;
}
}
streetInput.value = (streetNumber + ' ' + streetName).trim();
var cityInput = document.getElementById('clientCity');
if (cityInput) cityInput.value = city;
var provInput = document.getElementById('clientProvince');
if (provInput) provInput.value = province;
var postalInput = document.getElementById('clientPostal');
if (postalInput) postalInput.value = postalCode;
});
};
})();

View File

@@ -1,94 +1,234 @@
/**
* Technician Location Logger
* Logs GPS location every 5 minutes during working hours (9 AM - 6 PM)
* Only logs while the browser tab is visible.
* Technician Location Services
*
* 1. Background logger -- logs GPS every 5 minutes while the tech is clocked in.
* 2. getLocation() -- returns a Promise that resolves to {latitude, longitude, accuracy}.
* If the user denies permission or the request times out a blocking modal is shown
* and the promise is rejected.
* 3. Blocking modal -- cannot be dismissed; forces the technician to grant permission.
*/
(function () {
'use strict';
var INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
var STORE_OPEN_HOUR = 9;
var STORE_CLOSE_HOUR = 18;
var INTERVAL_MS = 5 * 60 * 1000;
var CLOCK_CHECK_MS = 60 * 1000; // check clock status every 60s
var locationTimer = null;
var clockCheckTimer = null;
var isClockedIn = false;
var permissionDenied = false;
function isWorkingHours() {
var now = new Date();
var hour = now.getHours();
return hour >= STORE_OPEN_HOUR && hour < STORE_CLOSE_HOUR;
// =====================================================================
// BLOCKING MODAL
// =====================================================================
var modalEl = null;
function ensureModal() {
if (modalEl) return;
var div = document.createElement('div');
div.id = 'fusionLocationModal';
div.innerHTML =
'<div style="position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:99999;display:flex;align-items:center;justify-content:center;">' +
'<div style="background:#fff;border-radius:16px;max-width:400px;width:90%;padding:2rem;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.3);">' +
'<div style="font-size:3rem;color:#dc3545;margin-bottom:1rem;"><i class="fa fa-map-marker"></i></div>' +
'<h4 style="margin-bottom:0.5rem;">Location Required</h4>' +
'<p style="color:#666;font-size:0.95rem;">Your GPS location is mandatory to perform this action. ' +
'Please allow location access in your browser settings and try again.</p>' +
'<p style="color:#999;font-size:0.85rem;">If you previously denied access, open your browser settings ' +
'and reset the location permission for this site.</p>' +
'<button id="fusionLocationRetryBtn" style="background:#0d6efd;color:#fff;border:none;border-radius:12px;padding:0.75rem 2rem;font-size:1rem;cursor:pointer;margin-top:0.5rem;width:100%;">' +
'<i class="fa fa-refresh" style="margin-right:6px;"></i>Try Again' +
'</button>' +
'</div>' +
'</div>';
document.body.appendChild(div);
modalEl = div;
document.getElementById('fusionLocationRetryBtn').addEventListener('click', function () {
hideModal();
window.fusionGetLocation().catch(function () {
showModal();
});
});
}
function isTechnicianPortal() {
// Check if we're on a technician portal page
return window.location.pathname.indexOf('/my/technician') !== -1;
function showModal() {
ensureModal();
modalEl.style.display = '';
}
function logLocation() {
if (!isWorkingHours()) {
return;
function hideModal() {
if (modalEl) modalEl.style.display = 'none';
}
if (document.hidden) {
return;
// =====================================================================
// PERMISSION-DENIED BANNER (persistent warning for background logger)
// =====================================================================
var bannerEl = null;
function showDeniedBanner() {
if (bannerEl) return;
bannerEl = document.createElement('div');
bannerEl.id = 'fusionLocationBanner';
bannerEl.style.cssText =
'position:fixed;top:0;left:0;right:0;z-index:9999;background:#dc3545;color:#fff;' +
'padding:10px 16px;text-align:center;font-size:0.9rem;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,.2);';
bannerEl.innerHTML =
'<i class="fa fa-exclamation-triangle" style="margin-right:6px;"></i>' +
'Location access is denied. Your location is not being tracked. ' +
'Please enable location in browser settings.';
document.body.appendChild(bannerEl);
}
// =====================================================================
// getLocation() -- public API
// =====================================================================
function getLocation() {
return new Promise(function (resolve, reject) {
if (!navigator.geolocation) {
reject(new Error('Geolocation is not supported by this browser.'));
return;
}
navigator.geolocation.getCurrentPosition(
function (position) {
var data = {
jsonrpc: '2.0',
method: 'call',
params: {
permissionDenied = false;
if (bannerEl) { bannerEl.remove(); bannerEl = null; }
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy || 0,
});
},
function (error) {
permissionDenied = true;
showDeniedBanner();
console.error('Fusion Location: GPS error', error.code, error.message);
reject(error);
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 30000 }
);
});
}
};
window.fusionGetLocation = getLocation;
// =====================================================================
// NAVIGATE -- opens Google Maps app on iOS/Android, browser fallback
// =====================================================================
function openGoogleMapsNav(el) {
var addr = (el.dataset.navAddr || '').trim();
var fallbackUrl = el.dataset.navUrl || '';
if (!addr && !fallbackUrl) return;
var dest = encodeURIComponent(addr) || fallbackUrl.split('destination=')[1];
var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
var isAndroid = /Android/i.test(navigator.userAgent);
if (isIOS) {
window.location.href = 'comgooglemaps://?daddr=' + dest + '&directionsmode=driving';
} else if (isAndroid) {
window.location.href = 'google.navigation:q=' + dest;
} else {
window.open(fallbackUrl, '_blank');
}
}
window.openGoogleMapsNav = openGoogleMapsNav;
// =====================================================================
// BACKGROUND LOGGER (tied to clock-in / clock-out status)
// =====================================================================
function isTechnicianPortal() {
return window.location.pathname.indexOf('/my/technician') !== -1;
}
function checkClockStatus() {
fetch('/my/technician/clock-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: {} }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
var wasClocked = isClockedIn;
isClockedIn = !!(data.result && data.result.clocked_in);
if (isClockedIn && !wasClocked) {
// Just clocked in — start tracking immediately
startLocationTimer();
} else if (!isClockedIn && wasClocked) {
// Just clocked out — stop tracking
stopLocationTimer();
}
})
.catch(function () {
/* network error: keep current state */
});
}
function logLocation() {
if (!isClockedIn || document.hidden || !navigator.geolocation) return;
getLocation().then(function (coords) {
fetch('/my/technician/location/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).catch(function () {
// Silently fail - location logging is best-effort
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy,
}
}),
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.result && !data.result.success) {
console.warn('Fusion Location: server rejected log', data.result);
}
})
.catch(function (err) {
console.warn('Fusion Location: network error', err);
});
}).catch(function () {
/* permission denied -- banner already shown */
});
},
function () {
// Geolocation permission denied or error - silently ignore
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
}
function startLocationLogging() {
if (!isTechnicianPortal()) {
return;
}
// Log immediately on page load
logLocation();
// Set interval for periodic logging
function startLocationTimer() {
if (locationTimer) return; // already running
logLocation(); // immediate first log
locationTimer = setInterval(logLocation, INTERVAL_MS);
}
// Pause/resume on tab visibility change
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
// Tab hidden - clear interval to save battery
function stopLocationTimer() {
if (locationTimer) {
clearInterval(locationTimer);
locationTimer = null;
}
} else {
// Tab visible again - log immediately and restart interval
logLocation();
if (!locationTimer) {
locationTimer = setInterval(logLocation, INTERVAL_MS);
}
function startLocationLogging() {
if (!isTechnicianPortal()) return;
// Check clock status immediately, then every 60s
checkClockStatus();
clockCheckTimer = setInterval(checkClockStatus, CLOCK_CHECK_MS);
// Pause/resume on tab visibility
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLocationTimer();
} else if (isClockedIn) {
startLocationTimer();
}
});
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startLocationLogging);
} else {

View File

@@ -51,19 +51,25 @@ class PDFTemplateFiller:
for page_idx in range(num_pages):
page = original.getPage(page_idx)
page_num = page_idx + 1 # 1-based page number
page_w = float(page.mediaBox.getWidth())
page_h = float(page.mediaBox.getHeight())
mb = page.mediaBox
page_w = float(mb.getWidth())
page_h = float(mb.getHeight())
origin_x = float(mb.getLowerLeft_x())
origin_y = float(mb.getLowerLeft_y())
fields = fields_by_page.get(page_num, [])
if fields:
# Create a transparent overlay for this page
overlay_buf = BytesIO()
c = canvas.Canvas(overlay_buf, pagesize=(page_w, page_h))
c = canvas.Canvas(
overlay_buf,
pagesize=(origin_x + page_w, origin_y + page_h),
)
for field in fields:
PDFTemplateFiller._draw_field(
c, field, context, signatures, page_w, page_h
c, field, context, signatures,
page_w, page_h, origin_x, origin_y,
)
c.save()
@@ -80,7 +86,8 @@ class PDFTemplateFiller:
return result.getvalue()
@staticmethod
def _draw_field(c, field, context, signatures, page_w, page_h):
def _draw_field(c, field, context, signatures,
page_w, page_h, origin_x=0, origin_y=0):
"""Draw a single field onto the reportlab canvas.
Args:
@@ -90,6 +97,8 @@ class PDFTemplateFiller:
signatures: dict of {field_key: binary} for signature fields
page_w: page width in PDF points
page_h: page height in PDF points
origin_x: mediaBox lower-left X (accounts for non-zero origin)
origin_y: mediaBox lower-left Y (accounts for non-zero origin)
"""
field_key = field.get('field_key') or field.get('field_name', '')
field_type = field.get('field_type', 'text')
@@ -98,11 +107,12 @@ class PDFTemplateFiller:
if not value and field_type != 'signature':
return
# Convert percentage positions to absolute PDF coordinates
# pos_x/pos_y are 0.0-1.0 ratios from top-left
# PDF coordinate system: origin at bottom-left, Y goes up
abs_x = field['pos_x'] * page_w
abs_y = page_h - (field['pos_y'] * page_h) # flip Y axis
# Convert percentage positions to absolute PDF coordinates.
# pos_x/pos_y are 0.0-1.0 ratios from top-left of the visible page.
# PDF coordinate system: origin at bottom-left, Y goes up.
# origin_x/origin_y account for PDFs whose mediaBox doesn't start at (0,0).
abs_x = field['pos_x'] * page_w + origin_x
abs_y = (origin_y + page_h) - (field['pos_y'] * page_h)
font_name = field.get('font_name', 'Helvetica')
font_size = field.get('font_size', 10.0)
@@ -124,10 +134,22 @@ class PDFTemplateFiller:
elif field_type == 'checkbox':
if value:
c.setFont('ZapfDingbats', font_size)
# Draw a cross mark (✗) that fills the checkbox box
cb_w = field.get('width', 0.015) * page_w
cb_h = field.get('height', 0.018) * page_h
cb_y = abs_y - cb_h + (cb_h - font_size) / 2
c.drawString(abs_x, cb_y, '4')
# Inset slightly so the cross doesn't touch the box edges
pad = min(cb_w, cb_h) * 0.15
x1 = abs_x + pad
y1 = abs_y - cb_h + pad
x2 = abs_x + cb_w - pad
y2 = abs_y - pad
c.saveState()
c.setStrokeColorRGB(0, 0, 0)
c.setLineWidth(1.5)
# Draw X (two diagonal lines)
c.line(x1, y1, x2, y2)
c.line(x1, y2, x2, y1)
c.restoreState()
elif field_type == 'signature':
sig_data = signatures.get(field_key)

View File

@@ -0,0 +1,413 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ============================================================ -->
<!-- Page 11 Public Signing Form -->
<!-- ============================================================ -->
<template id="portal_page11_public_sign" name="Page 11 - Sign">
<t t-call="portal.frontend_layout">
<div class="container py-4" style="max-width:720px;">
<div class="text-center mb-4">
<t t-if="company.logo">
<img t-att-src="'/web/image/res.company/%s/logo/200x60' % company.id"
alt="Company Logo" style="max-height:60px;" class="mb-2"/>
</t>
<h3 class="mb-1">ADP Consent and Declaration</h3>
<p class="text-muted">Page 11 - Assistive Devices Program</p>
</div>
<t t-if="request.params.get('error') == 'no_signature'">
<div class="alert alert-danger">Please draw your signature before submitting.</div>
</t>
<t t-if="request.params.get('error') == 'no_consent'">
<div class="alert alert-danger">You must accept the consent declaration before signing.</div>
</t>
<!-- Consent Declaration -->
<form method="POST" t-att-action="'/page11/sign/%s/submit' % token" id="page11Form">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<!-- Applicant Information -->
<div class="card mb-3">
<div class="card-header"><strong>Applicant Information</strong></div>
<div class="card-body">
<div class="row mb-2">
<div class="col-sm-4">
<label class="form-label">Last Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="client_last_name"
t-att-value="client_last_name or ''" required="required"/>
</div>
<div class="col-sm-4">
<label class="form-label">First Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="client_first_name"
t-att-value="client_first_name or ''" required="required"/>
</div>
<div class="col-sm-4">
<label class="form-label">Middle Name</label>
<input type="text" class="form-control" name="client_middle_name"
t-att-value="client_middle_name or ''"/>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-6">
<label class="form-label">Health Card Number (10 digits) <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="client_health_card"
t-att-value="client_health_card or ''" required="required"
maxlength="10" pattern="[0-9]{10}" title="10-digit health card number"
placeholder="e.g. 1234567890"/>
</div>
<div class="col-sm-3">
<label class="form-label">Version <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="client_health_card_version"
t-att-value="client_health_card_version or ''" required="required"
maxlength="2" placeholder="e.g. AB"/>
</div>
<div class="col-sm-3">
<label class="form-label">Case Ref</label>
<input type="text" class="form-control" readonly="readonly"
t-att-value="order.name"/>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><strong>Consent and Declaration</strong></div>
<div class="card-body">
<p class="small">
I consent to information being collected and used by the Ministry of Health and Long-Term Care,
and agents authorized by the Ministry, for the administration and enforcement of the
Assistive Devices Program. I understand this consent is voluntary and I may withdraw it
at any time. I declare that the information in this application is true and complete.
</p>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="consent_declaration"
name="consent_declaration" required="required"/>
<label class="form-check-label" for="consent_declaration">
<strong>I have read and accept the above declaration.</strong>
</label>
</div>
<div class="mb-3">
<label for="signer_type" class="form-label"><strong>I am signing as:</strong></label>
<select class="form-select" id="signer_type" name="signer_type" required="required"
onchange="toggleAgentFields()">
<option value="client" t-att-selected="signer_type == 'client' and 'selected'">Applicant (Client - Self)</option>
<option value="spouse" t-att-selected="signer_type == 'spouse' and 'selected'">Spouse</option>
<option value="parent" t-att-selected="signer_type == 'parent' and 'selected'">Parent</option>
<option value="legal_guardian" t-att-selected="signer_type == 'legal_guardian' and 'selected'">Legal Guardian</option>
<option value="poa" t-att-selected="signer_type == 'poa' and 'selected'">Power of Attorney</option>
<option value="public_trustee" t-att-selected="signer_type == 'public_trustee' and 'selected'">Public Trustee</option>
</select>
</div>
<div class="mb-3">
<label for="signer_name" class="form-label">Full Name</label>
<input type="text" class="form-control" id="signer_name" name="signer_name"
t-att-value="sign_request.signer_name or ''" required="required"/>
</div>
</div>
</div>
<!-- Agent Details (shown/hidden via JS based on signer type selection) -->
<div class="card mb-3" id="agent_details_card" t-att-style="'' if is_agent else 'display:none;'">
<div class="card-header"><strong>Agent Details</strong></div>
<div class="card-body">
<div class="row mb-2">
<div class="col-sm-5">
<label class="form-label">Last Name</label>
<input type="text" class="form-control agent-field" name="agent_last_name"/>
</div>
<div class="col-sm-5">
<label class="form-label">First Name</label>
<input type="text" class="form-control agent-field" name="agent_first_name"/>
</div>
<div class="col-sm-2">
<label class="form-label">M.I.</label>
<input type="text" class="form-control" name="agent_middle_initial"
maxlength="2" placeholder="M"/>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-6">
<label class="form-label">Home Phone</label>
<input type="tel" class="form-control" name="agent_phone"/>
</div>
<div class="col-sm-6">
<label class="form-label">Business Phone</label>
<input type="tel" class="form-control" name="agent_business_phone"/>
</div>
</div>
<div class="mb-2">
<label class="form-label">Search Address</label>
<input type="text" class="form-control" id="agent_street_search"
placeholder="Start typing an address..." autocomplete="off"/>
</div>
<div class="row mb-2">
<div class="col-sm-3">
<label class="form-label">Unit #</label>
<input type="text" class="form-control" name="agent_unit" id="agent_unit"/>
</div>
<div class="col-sm-3">
<label class="form-label">Street #</label>
<input type="text" class="form-control" name="agent_street_number" id="agent_street_number"/>
</div>
<div class="col-sm-6">
<label class="form-label">Street Name</label>
<input type="text" class="form-control" name="agent_street" id="agent_street"/>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-5">
<label class="form-label">City/Town</label>
<input type="text" class="form-control" name="agent_city" id="agent_city"/>
</div>
<div class="col-sm-4">
<label class="form-label">Province</label>
<input type="text" class="form-control" name="agent_province" id="agent_province" value="Ontario"/>
</div>
<div class="col-sm-3">
<label class="form-label">Postal Code</label>
<input type="text" class="form-control" name="agent_postal_code" id="agent_postal_code"/>
</div>
</div>
</div>
</div>
<!-- Signature Pad -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Signature</strong>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="clearSignature()">Clear</button>
</div>
<div class="card-body p-2">
<canvas id="signature-canvas" width="660" height="200"
style="border:1px dashed rgba(128,128,128,0.35);border-radius:6px;width:100%;touch-action:none;cursor:crosshair;">
</canvas>
<input type="hidden" name="signature_data" id="signature_data"/>
</div>
</div>
<div class="text-center mb-4">
<button type="submit" class="btn btn-primary btn-lg px-5" onclick="return prepareSubmit()">
Submit Signature
</button>
</div>
</form>
<p class="text-center text-muted small">
<t t-out="company.name"/> &amp;middot;
<t t-if="company.phone"><t t-out="company.phone"/> &amp;middot; </t>
<t t-if="company.email"><t t-out="company.email"/></t>
</p>
</div>
<script type="text/javascript">
function toggleAgentFields() {
var sel = document.getElementById('signer_type');
var card = document.getElementById('agent_details_card');
var agentFields = card ? card.querySelectorAll('.agent-field') : [];
var isAgent = sel.value !== 'client';
if (card) card.style.display = isAgent ? '' : 'none';
agentFields.forEach(function(f) {
if (isAgent) { f.setAttribute('required', 'required'); }
else { f.removeAttribute('required'); f.value = ''; }
});
}
document.addEventListener('DOMContentLoaded', toggleAgentFields);
</script>
<script type="text/javascript">
(function() {
var canvas = document.getElementById('signature-canvas');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var drawing = false;
var lastX = 0, lastY = 0;
var hasDrawn = false;
function resizeCanvas() {
var rect = canvas.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
resizeCanvas();
function getPos(e) {
var rect = canvas.getBoundingClientRect();
var touch = e.touches ? e.touches[0] : e;
return {
x: touch.clientX - rect.left,
y: touch.clientY - rect.top
};
}
function startDraw(e) {
e.preventDefault();
drawing = true;
var pos = getPos(e);
lastX = pos.x;
lastY = pos.y;
}
function draw(e) {
if (!drawing) return;
e.preventDefault();
var pos = getPos(e);
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
lastX = pos.x;
lastY = pos.y;
hasDrawn = true;
}
function stopDraw(e) {
if (e) e.preventDefault();
drawing = false;
}
canvas.addEventListener('mousedown', startDraw);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDraw);
canvas.addEventListener('mouseleave', stopDraw);
canvas.addEventListener('touchstart', startDraw, {passive: false});
canvas.addEventListener('touchmove', draw, {passive: false});
canvas.addEventListener('touchend', stopDraw, {passive: false});
window.clearSignature = function() {
var dpr = window.devicePixelRatio || 1;
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
hasDrawn = false;
document.getElementById('signature_data').value = '';
};
window.prepareSubmit = function() {
if (!hasDrawn) {
alert('Please draw your signature before submitting.');
return false;
}
var dataUrl = canvas.toDataURL('image/png');
document.getElementById('signature_data').value = dataUrl;
return true;
};
})();
</script>
<t t-if="google_maps_api_key">
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&amp;libraries=places&amp;callback=initPage11AddressAutocomplete" async="async" defer="defer"></script>
<script type="text/javascript">
function initPage11AddressAutocomplete() {
var searchInput = document.getElementById('agent_street_search');
if (!searchInput) return;
var autocomplete = new google.maps.places.Autocomplete(searchInput, {
types: ['address'],
componentRestrictions: { country: 'ca' }
});
autocomplete.setFields(['address_components', 'formatted_address']);
autocomplete.addListener('place_changed', function() {
var place = autocomplete.getPlace();
if (!place || !place.address_components) return;
var street_number = '', route = '', city = '', province = '', postal = '', unit = '';
place.address_components.forEach(function(c) {
var t = c.types;
if (t.indexOf('street_number') >= 0) street_number = c.long_name;
else if (t.indexOf('route') >= 0) route = c.long_name;
else if (t.indexOf('locality') >= 0) city = c.long_name;
else if (t.indexOf('sublocality') >= 0 &amp;&amp; !city) city = c.long_name;
else if (t.indexOf('administrative_area_level_1') >= 0) province = c.long_name;
else if (t.indexOf('postal_code') >= 0) postal = c.long_name;
else if (t.indexOf('subpremise') >= 0) unit = c.long_name;
});
document.getElementById('agent_street_number').value = street_number;
document.getElementById('agent_street').value = route;
document.getElementById('agent_city').value = city;
document.getElementById('agent_province').value = province;
document.getElementById('agent_postal_code').value = postal;
if (unit) document.getElementById('agent_unit').value = unit;
});
}
</script>
</t>
</t>
</template>
<!-- ============================================================ -->
<!-- Success Page -->
<!-- ============================================================ -->
<template id="portal_page11_sign_success" name="Page 11 - Signed Successfully">
<t t-call="portal.frontend_layout">
<div class="container py-5" style="max-width:600px;">
<div class="text-center">
<div class="mb-4">
<i class="fa fa-check-circle text-success" style="font-size:64px;"/>
</div>
<h3>Signature Submitted Successfully</h3>
<p class="text-muted mt-3">
Thank you for signing the ADP Consent and Declaration form.
Your signature has been recorded and the document has been updated.
</p>
<t t-if="sign_request and sign_request.sale_order_id">
<p class="text-muted">
Case Reference: <strong><t t-out="sign_request.sale_order_id.name"/></strong>
</p>
</t>
<t t-if="sign_request and sign_request.signed_pdf and token">
<a t-attf-href="/page11/sign/#{token}/download"
class="btn btn-outline-primary mt-3">
<i class="fa fa-download"/> Download Signed PDF
</a>
</t>
<p class="text-muted mt-4 small">You may close this window.</p>
</div>
</div>
</t>
</template>
<!-- ============================================================ -->
<!-- Expired / Cancelled Page -->
<!-- ============================================================ -->
<template id="portal_page11_sign_expired" name="Page 11 - Link Expired">
<t t-call="portal.frontend_layout">
<div class="container py-5" style="max-width:600px;">
<div class="text-center">
<div class="mb-4">
<i class="fa fa-clock-o text-warning" style="font-size:64px;"/>
</div>
<h3>Signing Link Expired</h3>
<p class="text-muted mt-3">
This signing link is no longer valid. It may have expired or been cancelled.
</p>
<p class="text-muted">
Please contact the office to request a new signing link.
</p>
</div>
</div>
</t>
</template>
<!-- ============================================================ -->
<!-- Invalid / Not Found Page -->
<!-- ============================================================ -->
<template id="portal_page11_sign_invalid" name="Page 11 - Invalid Link">
<t t-call="portal.frontend_layout">
<div class="container py-5" style="max-width:600px;">
<div class="text-center">
<div class="mb-4">
<i class="fa fa-exclamation-triangle text-danger" style="font-size:64px;"/>
</div>
<h3>Invalid Link</h3>
<p class="text-muted mt-3">
This signing link is not valid. Please check that you are using the correct link
from the email you received.
</p>
</div>
</div>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,348 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ==================== SCHEDULE OVERVIEW PAGE ==================== -->
<template id="portal_schedule_page" name="My Schedule">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="container py-4">
<!-- Success/Error Messages -->
<t t-if="request.params.get('success')">
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fa fa-check-circle me-2"/><t t-out="request.params.get('success')"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h3 class="mb-1"><i class="fa fa-calendar-check-o me-2"/>My Schedule</h3>
<p class="text-muted mb-0">View your appointments and book new ones</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<t t-if="share_url">
<div class="input-group" style="max-width: 350px;">
<input type="text" class="form-control form-control-sm" t-att-value="share_url"
id="shareBookingUrl" readonly="readonly" style="font-size: 13px;"/>
<button class="btn btn-outline-secondary btn-sm" type="button"
id="btnCopyShareUrl">
<i class="fa fa-copy" id="copyIcon"/> <span id="copyText">Copy</span>
</button>
<script type="text/javascript">
(function() {
var btn = document.getElementById('btnCopyShareUrl');
if (!btn) return;
btn.addEventListener('click', function() {
var url = document.getElementById('shareBookingUrl').value;
navigator.clipboard.writeText(url);
var icon = document.getElementById('copyIcon');
var text = document.getElementById('copyText');
icon.className = 'fa fa-check';
text.textContent = 'Copied';
setTimeout(function() {
icon.className = 'fa fa-copy';
text.textContent = 'Copy';
}, 2000);
});
})();
</script>
</div>
</t>
<a href="/my/schedule/book" class="btn btn-primary">
<i class="fa fa-plus me-1"/> Book Appointment
</a>
</div>
</div>
<!-- Today's Appointments -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom-0 pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0"><i class="fa fa-sun-o me-2 text-warning"/>Today's Appointments</h5>
</div>
<div class="card-body px-4 pb-4 pt-2">
<t t-if="today_events">
<div class="list-group list-group-flush">
<t t-foreach="today_events" t-as="event">
<div class="list-group-item px-0 py-3 border-start-0 border-end-0">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="rounded-3 text-center px-3 py-2 me-3"
t-attf-style="background: #{portal_gradient}; min-width: 70px;">
<div class="text-white fw-bold" style="font-size: 14px;">
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M')"/>
</div>
<div class="text-white" style="font-size: 10px;">
<t t-out="event.start.astimezone(user_tz).strftime('%p')"/>
</div>
</div>
<div>
<h6 class="mb-0"><t t-out="event.name"/></h6>
<small class="text-muted">
<t t-if="event.location">
<i class="fa fa-map-marker me-1"/><t t-out="event.location"/>
</t>
</small>
</div>
</div>
<div class="text-end">
<span class="badge bg-light text-dark">
<t t-out="'%.0f' % (event.duration * 60)"/> min
</span>
</div>
</div>
</div>
</t>
</div>
</t>
<t t-else="">
<p class="text-muted mb-0 py-3 text-center">
<i class="fa fa-calendar-o me-1"/> No appointments scheduled for today.
</p>
</t>
</div>
</div>
<!-- Upcoming Appointments -->
<div class="card border-0 shadow-sm" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom-0 pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0"><i class="fa fa-calendar me-2 text-primary"/>Upcoming Appointments</h5>
</div>
<div class="card-body px-4 pb-4 pt-2">
<t t-if="upcoming_events">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="border-top:none;">Date</th>
<th style="border-top:none;">Time</th>
<th style="border-top:none;">Appointment</th>
<th style="border-top:none;">Location</th>
<th style="border-top:none;">Duration</th>
</tr>
</thead>
<tbody>
<t t-foreach="upcoming_events" t-as="event">
<tr>
<td>
<strong><t t-out="event.start.astimezone(user_tz).strftime('%b %d')"/></strong>
<br/>
<small class="text-muted">
<t t-out="event.start.astimezone(user_tz).strftime('%A')"/>
</small>
</td>
<td>
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/>
</td>
<td><t t-out="event.name"/></td>
<td>
<t t-if="event.location">
<small><t t-out="event.location"/></small>
</t>
<t t-else="">
<small class="text-muted">-</small>
</t>
</td>
<td>
<span class="badge bg-light text-dark">
<t t-out="'%.0f' % (event.duration * 60)"/> min
</span>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
<t t-else="">
<p class="text-muted mb-0 py-3 text-center">
<i class="fa fa-calendar-o me-1"/> No upcoming appointments.
<a href="/my/schedule/book">Book one now</a>
</p>
</t>
</div>
</div>
</div>
</t>
</template>
<!-- ==================== BOOKING FORM ==================== -->
<template id="portal_schedule_book" name="Book Appointment">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="container py-4" style="max-width: 800px;">
<!-- Error Messages -->
<t t-if="error">
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fa fa-exclamation-circle me-2"/><t t-out="error"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<!-- Header -->
<div class="mb-4">
<a href="/my/schedule" class="text-muted text-decoration-none mb-2 d-inline-block">
<i class="fa fa-arrow-left me-1"/> Back to Schedule
</a>
<h3 class="mb-1"><i class="fa fa-plus-circle me-2"/>Book Appointment</h3>
<p class="text-muted mb-0">Select a time slot and enter client details</p>
</div>
<form action="/my/schedule/book/submit" method="post" id="bookingForm">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<!-- Step 1: Appointment Type + Date/Time -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0">
<span class="badge rounded-pill me-2"
t-attf-style="background: #{portal_gradient};">1</span>
Date &amp; Time
</h5>
</div>
<div class="card-body px-4 pb-4">
<!-- Appointment Type (if multiple) -->
<t t-if="len(appointment_types) > 1">
<div class="mb-3">
<label class="form-label fw-semibold">Appointment Type</label>
<select name="appointment_type_id" class="form-select"
id="appointmentTypeSelect">
<t t-foreach="appointment_types" t-as="atype">
<option t-att-value="atype.id"
t-att-selected="atype.id == selected_type.id"
t-att-data-duration="atype.appointment_duration">
<t t-out="atype.name"/>
(<t t-out="'%.0f' % (atype.appointment_duration * 60)"/> min)
</option>
</t>
</select>
</div>
</t>
<t t-else="">
<input type="hidden" name="appointment_type_id"
t-att-value="selected_type.id"/>
</t>
<!-- Date Picker -->
<div class="mb-3">
<label class="form-label fw-semibold">Select Date</label>
<input type="date" class="form-control" id="bookingDate"
required="required"
t-att-min="now.strftime('%Y-%m-%d')"/>
</div>
<!-- Week Calendar Preview -->
<div id="weekCalendarContainer" class="mb-3" style="display: none;">
<label class="form-label fw-semibold">
<i class="fa fa-calendar me-1"/>Your Week
</label>
<div id="weekCalendarLoading" class="text-center py-3" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
Loading calendar...
</div>
<div id="weekCalendarGrid" class="border rounded-3 overflow-hidden" style="display: none;">
<div id="weekCalendarHeader" class="d-flex bg-light border-bottom" style="min-height: 40px;"></div>
<div id="weekCalendarBody" class="d-flex" style="min-height: 80px;"></div>
</div>
<div id="weekCalendarEmpty" class="text-muted py-2 text-center" style="display: none;">
<i class="fa fa-calendar-o me-1"/> No events this week -- your schedule is open.
</div>
</div>
<!-- Available Slots -->
<div id="slotsContainer" style="display: none;">
<label class="form-label fw-semibold">Available Time Slots</label>
<div id="slotsLoading" class="text-center py-3" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
Loading available slots...
</div>
<div id="slotsGrid" class="d-flex flex-wrap gap-2 mb-2"></div>
<div id="noSlots" class="text-muted py-2" style="display: none;">
<i class="fa fa-info-circle me-1"/> No available slots for this date.
Try another date.
</div>
<input type="hidden" name="slot_datetime" id="slotDatetime"/>
<input type="hidden" name="slot_duration" id="slotDuration"
t-att-value="selected_type.appointment_duration"/>
</div>
</div>
</div>
<!-- Step 2: Client Details -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0">
<span class="badge rounded-pill me-2"
t-attf-style="background: #{portal_gradient};">2</span>
Client Details
</h5>
</div>
<div class="card-body px-4 pb-4">
<div class="mb-3">
<label class="form-label fw-semibold">Client Name <span class="text-danger">*</span></label>
<input type="text" name="client_name" class="form-control"
placeholder="Enter client's full name" required="required"/>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Address</label>
<input type="text" name="client_street" class="form-control mb-2"
id="clientStreet"
placeholder="Start typing address..."/>
</div>
<div class="row g-2 mb-3">
<div class="col-md-4">
<input type="text" name="client_city" class="form-control"
id="clientCity" placeholder="City"/>
</div>
<div class="col-md-4">
<input type="text" name="client_province" class="form-control"
id="clientProvince" placeholder="Province"/>
</div>
<div class="col-md-4">
<input type="text" name="client_postal" class="form-control"
id="clientPostal" placeholder="Postal Code"/>
</div>
</div>
<div class="mb-0">
<label class="form-label fw-semibold">Notes</label>
<textarea name="notes" class="form-control" rows="3"
placeholder="e.g. Equipment to bring, special instructions, reason for visit..."></textarea>
</div>
</div>
</div>
<!-- Submit -->
<div class="d-flex justify-content-between">
<a href="/my/schedule" class="btn btn-outline-secondary">
<i class="fa fa-arrow-left me-1"/> Cancel
</a>
<button type="submit" class="btn btn-primary btn-lg px-4" id="btnSubmitBooking"
disabled="disabled">
<i class="fa fa-calendar-check-o me-1"/> Book Appointment
</button>
</div>
</form>
</div>
<!-- Google Maps Places API -->
<t t-if="google_maps_api_key">
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&amp;libraries=places&amp;callback=initScheduleAddressAutocomplete"
async="async" defer="defer"></script>
</t>
<script t-attf-src="/fusion_authorizer_portal/static/src/js/portal_schedule_booking.js"></script>
</t>
</template>
</odoo>

View File

@@ -18,6 +18,41 @@
</ol>
</nav>
<!-- Clock In/Out -->
<t t-if="clock_enabled">
<div class="tech-clock-card mb-3"
id="techClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<div class="tech-clock-dot" t-att-class="'tech-clock-dot--active' if clock_checked_in else ''"/>
<div>
<div class="tech-clock-status" id="clockStatusText">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Not Clocked In</t>
</div>
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
</div>
</div>
<button class="tech-clock-btn" id="clockActionBtn"
t-att-class="'tech-clock-btn--out' if clock_checked_in else 'tech-clock-btn--in'"
onclick="handleClockAction()">
<t t-if="clock_checked_in">
<i class="fa fa-stop-circle-o"/> Clock Out
</t>
<t t-else="">
<i class="fa fa-play-circle-o"/> Clock In
</t>
</button>
</div>
<div class="tech-clock-error" id="clockError" style="display:none;">
<i class="fa fa-exclamation-triangle"/>
<span id="clockErrorText"/>
</div>
</div>
</t>
<!-- Quick Stats Bar -->
<div class="tech-stats-bar mb-4">
<div class="tech-stat-card tech-stat-total">
@@ -32,10 +67,6 @@
<div class="stat-number"><t t-out="completed_today"/></div>
<div class="stat-label">Done</div>
</div>
<div class="tech-stat-card tech-stat-travel">
<div class="stat-number"><t t-out="total_travel"/></div>
<div class="stat-label">Travel min</div>
</div>
</div>
<!-- Current / Next Task Hero Card -->
@@ -55,21 +86,24 @@
</div>
<span class="text-muted"><t t-out="current_task.time_start_display"/> - <t t-out="current_task.time_end_display"/></span>
</div>
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="current_task.partner_id.name or 'N/A'"/></p>
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="current_task.client_display_name or 'N/A'"/></p>
<p class="mb-2 text-muted"><i class="fa fa-map-marker me-1"/><t t-out="current_task.address_display or 'No address'"/></p>
<t t-if="current_task.description">
<p class="mb-2 small"><t t-out="current_task.description"/></p>
</t>
<div class="d-flex gap-2 flex-wrap mt-3">
<a t-if="current_task.get_google_maps_url()" t-att-href="current_task.get_google_maps_url()"
class="tech-action-btn tech-btn-navigate" target="_blank">
<a t-if="current_task.get_google_maps_url()"
href="#" class="tech-action-btn tech-btn-navigate"
t-att-data-nav-url="current_task.get_google_maps_url()"
t-att-data-nav-addr="current_task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>Navigate
</a>
<a t-attf-href="/my/technician/task/#{current_task.id}"
class="tech-action-btn tech-btn-complete">
<i class="fa fa-check"/>Complete
</a>
<a t-if="current_task.partner_phone" t-attf-href="tel:#{current_task.partner_phone}"
<a t-if="current_task.client_display_phone" t-attf-href="tel:#{current_task.client_display_phone}"
class="tech-action-btn tech-btn-call">
<i class="fa fa-phone"/>Call
</a>
@@ -94,7 +128,7 @@
<span class="ms-2 fw-bold"><t t-out="next_task.name"/></span>
</div>
</div>
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="next_task.partner_id.name or 'N/A'"/></p>
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="next_task.client_display_name or 'N/A'"/></p>
<p class="mb-1 text-muted"><i class="fa fa-map-marker me-1"/><t t-out="next_task.address_display or 'No address'"/></p>
<t t-if="next_task.travel_time_minutes">
<p class="mb-2 small text-purple"><i class="fa fa-car me-1"/><t t-out="next_task.travel_time_minutes"/> min drive
@@ -102,8 +136,11 @@
</p>
</t>
<div class="d-flex gap-2 flex-wrap mt-3">
<a t-if="next_task.get_google_maps_url()" t-att-href="next_task.get_google_maps_url()"
class="tech-action-btn tech-btn-navigate" target="_blank">
<a t-if="next_task.get_google_maps_url()"
href="#" class="tech-action-btn tech-btn-navigate"
t-att-data-nav-url="next_task.get_google_maps_url()"
t-att-data-nav-addr="next_task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>Navigate
</a>
<button class="tech-action-btn tech-btn-enroute"
@@ -155,7 +192,7 @@
<span t-attf-class="tech-badge tech-badge-#{task.task_type} me-1">
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
</span>
<t t-out="task.partner_id.name or task.name"/>
<t t-out="task.client_display_name or task.name"/>
</div>
<div class="tech-timeline-meta">
<i class="fa fa-map-marker me-1"/><t t-out="task.address_city or 'No address'"/>
@@ -170,26 +207,23 @@
</t>
<!-- Quick Links -->
<div class="row g-2 mb-4">
<div class="col-4">
<a href="/my/technician/tasks" class="btn btn-outline-primary w-100 py-3">
<i class="fa fa-list me-1"/>All Tasks
<div class="tech-quick-links mb-4">
<a href="/my/technician/tasks" class="tech-quick-link tech-quick-link-primary">
<i class="fa fa-list"/>
<span>All Tasks</span>
</a>
</div>
<div class="col-4">
<a href="/my/technician/tomorrow" class="btn btn-outline-secondary w-100 py-3">
<i class="fa fa-calendar me-1"/>Tomorrow
<a href="/my/technician/tomorrow" class="tech-quick-link tech-quick-link-secondary">
<i class="fa fa-calendar"/>
<span>Tomorrow</span>
<t t-if="tomorrow_count">
<span class="badge bg-primary ms-1"><t t-out="tomorrow_count"/></span>
<span class="tech-quick-link-badge"><t t-out="tomorrow_count"/></span>
</t>
</a>
</div>
<div class="col-4">
<a href="/repair-form" class="btn btn-outline-warning w-100 py-3">
<i class="fa fa-wrench me-1"/>Repair Form
<a href="/repair-form" class="tech-quick-link tech-quick-link-warning">
<i class="fa fa-wrench"/>
<span>Repair Form</span>
</a>
</div>
</div>
<!-- My Start Location -->
<div class="tech-card mb-4">
@@ -221,16 +255,126 @@
</div>
</div>
<!-- Clock In/Out JS -->
<script type="text/javascript">
(function() {
var card = document.getElementById('techClockCard');
if (!card) return;
var isCheckedIn = card.dataset.checkedIn === 'true';
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
var timerInterval = null;
function updateTimer() {
if (!checkInTime) return;
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
var h = Math.floor(diff / 3600);
var m = Math.floor((diff % 3600) / 60);
var s = diff % 60;
var pad = function(n) { return n &lt; 10 ? '0' + n : '' + n; };
document.getElementById('clockTimer').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
}
function startTimer() {
stopTimer();
updateTimer();
timerInterval = setInterval(updateTimer, 1000);
}
function stopTimer() {
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
}
function applyState() {
var dot = card.querySelector('.tech-clock-dot');
var statusEl = document.getElementById('clockStatusText');
var btn = document.getElementById('clockActionBtn');
var timerEl = document.getElementById('clockTimer');
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
if (btn) {
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in');
btn.innerHTML = isCheckedIn
? '&lt;i class="fa fa-stop-circle-o">&lt;/i> Clock Out'
: '&lt;i class="fa fa-play-circle-o">&lt;/i> Clock In';
}
if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00';
}
if (isCheckedIn &amp;&amp; checkInTime) startTimer();
window.handleClockAction = function() {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
var errText = document.getElementById('clockErrorText');
btn.disabled = true;
errEl.style.display = 'none';
window.fusionGetLocation().then(function(coords) {
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy,
source: 'portal'
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var result = data.result || {};
if (result.error) {
errText.textContent = result.error;
errEl.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else {
isCheckedIn = false;
checkInTime = null;
stopTimer();
}
applyState();
btn.disabled = false;
})
.catch(function() {
errText.textContent = 'Network error. Please try again.';
errEl.style.display = 'flex';
btn.disabled = false;
});
}).catch(function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
});
};
})();
</script>
<!-- Inline JS for task actions -->
<script type="text/javascript">
function techTaskAction(btn, action) {
var taskId = btn.dataset.taskId;
var origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Getting location...';
window.fusionGetLocation().then(function(coords) {
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ...';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}})
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
action: action,
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
@@ -239,12 +383,18 @@
} else {
alert(data.result ? data.result.error : 'Error');
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-road"></i> En Route';
btn.innerHTML = origHtml;
}
})
.catch(function() {
alert('Network error. Please try again.');
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-road"></i> En Route';
btn.innerHTML = origHtml;
});
}).catch(function() {
alert('Location access is required. Please enable GPS and try again.');
btn.disabled = false;
btn.innerHTML = origHtml;
});
}
@@ -375,7 +525,7 @@
<span class="ms-1"><t t-out="task.time_start_display"/></span>
</span>
<span>
<i class="fa fa-user me-1"/><t t-out="task.partner_id.name or '-'"/>
<i class="fa fa-user me-1"/><t t-out="task.client_display_name or '-'"/>
</span>
</div>
<div class="text-muted small mt-1">
@@ -462,19 +612,22 @@
<!-- ===== QUICK ACTIONS ROW ===== -->
<div class="tech-quick-actions mb-3">
<t t-if="task.get_google_maps_url()">
<a t-att-href="task.get_google_maps_url()" class="tech-quick-btn" target="_blank">
<a href="#" class="tech-quick-btn"
t-att-data-nav-url="task.get_google_maps_url()"
t-att-data-nav-addr="task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>
<span>Navigate</span>
</a>
</t>
<t t-if="task.partner_phone">
<a t-attf-href="tel:#{task.partner_phone}" class="tech-quick-btn">
<t t-if="task.client_display_phone">
<a t-attf-href="tel:#{task.client_display_phone}" class="tech-quick-btn">
<i class="fa fa-phone"/>
<span>Call</span>
</a>
</t>
<t t-if="task.partner_phone">
<a t-attf-href="sms:#{task.partner_phone}" class="tech-quick-btn">
<t t-if="task.client_display_phone">
<a t-attf-href="sms:#{task.client_display_phone}" class="tech-quick-btn">
<i class="fa fa-comment"/>
<span>Text</span>
</a>
@@ -512,10 +665,10 @@
<i class="fa fa-user"/>
</div>
<div class="flex-grow-1">
<div class="fw-semibold"><t t-out="task.partner_id.name or 'No client'"/></div>
<t t-if="task.partner_phone">
<a t-attf-href="tel:#{task.partner_phone}" class="text-muted small text-decoration-none">
<i class="fa fa-phone me-1"/><t t-out="task.partner_phone"/>
<div class="fw-semibold"><t t-out="task.client_display_name or 'No client'"/></div>
<t t-if="task.client_display_phone">
<a t-attf-href="tel:#{task.client_display_phone}" class="text-muted small text-decoration-none">
<i class="fa fa-phone me-1"/><t t-out="task.client_display_phone"/>
</a>
</t>
</div>
@@ -565,8 +718,8 @@
</div>
</t>
<!-- ===== POD (if required and linked to a sale order) ===== -->
<t t-if="task.pod_required and task.sale_order_id">
<!-- ===== POD (if required) ===== -->
<t t-if="task.pod_required">
<div class="tech-card mb-3">
<div class="d-flex align-items-center">
<div class="tech-card-icon bg-warning-subtle text-warning">
@@ -574,14 +727,16 @@
</div>
<div class="flex-grow-1">
<div class="fw-semibold">Proof of Delivery</div>
<t t-if="task.sale_order_id.x_fc_pod_signature">
<t t-set="has_task_pod" t-value="bool(task.pod_signature)"/>
<t t-set="has_order_pod" t-value="bool(task.sale_order_id and task.sale_order_id.x_fc_pod_signature)"/>
<t t-if="has_task_pod or has_order_pod">
<span class="text-success small d-block"><i class="fa fa-check me-1"/>Signature collected</span>
<a t-attf-href="/my/pod/#{task.sale_order_id.id}" class="btn btn-sm btn-outline-warning mt-1">
<a t-attf-href="/my/technician/task/#{task.id}/pod" class="btn btn-sm btn-outline-warning mt-1">
<i class="fa fa-refresh me-1"/>Re-collect Signature
</a>
</t>
<t t-else="">
<a t-attf-href="/my/pod/#{task.sale_order_id.id}" class="btn btn-sm btn-warning mt-1">
<a t-attf-href="/my/technician/task/#{task.id}/pod" class="btn btn-sm btn-warning mt-1">
<i class="fa fa-pencil me-1"/>Collect Signature
</a>
</t>
@@ -693,10 +848,18 @@
t-att-data-task-id="task.id">
<i class="fa fa-play"/>Start
</button>
<button class="tech-action-btn tech-btn-complete"
onclick="techCompleteTask(this)"
t-att-data-task-id="task.id">
<i class="fa fa-check-circle"/>Complete
</button>
</t>
<t t-if="task.status == 'en_route'">
<a t-if="task.get_google_maps_url()" t-att-href="task.get_google_maps_url()"
class="tech-action-btn tech-btn-navigate" target="_blank">
<a t-if="task.get_google_maps_url()"
href="#" class="tech-action-btn tech-btn-navigate"
t-att-data-nav-url="task.get_google_maps_url()"
t-att-data-nav-addr="task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>Navigate
</a>
<button class="tech-action-btn tech-btn-start"
@@ -704,6 +867,11 @@
t-att-data-task-id="task.id">
<i class="fa fa-play"/>Start
</button>
<button class="tech-action-btn tech-btn-complete"
onclick="techCompleteTask(this)"
t-att-data-task-id="task.id">
<i class="fa fa-check-circle"/>Complete
</button>
</t>
<t t-if="task.status == 'in_progress'">
<button class="tech-action-btn tech-btn-complete"
@@ -750,12 +918,20 @@
var recordingSeconds = 0;
function techTaskAction(btn, action) {
var origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Getting location...';
window.fusionGetLocation().then(function(coords) {
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i>';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}})
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
action: action,
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
@@ -764,17 +940,36 @@
} else {
alert(data.result ? data.result.error : 'Error');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function() {
alert('Network error. Please try again.');
btn.disabled = false;
btn.innerHTML = origHtml;
});
}).catch(function() {
alert('Location access is required. Please enable GPS and try again.');
btn.disabled = false;
btn.innerHTML = origHtml;
});
}
function techCompleteTask(btn) {
var origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Getting location...';
window.fusionGetLocation().then(function(coords) {
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Completing...';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: 'complete'}})
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
action: 'complete',
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
@@ -783,13 +978,18 @@
} else {
alert(data.result ? data.result.error : 'Error completing task');
btn.disabled = false;
btn.innerHTML = '&lt;i class="fa fa-check-circle">&lt;/i> Complete Task';
btn.innerHTML = origHtml;
}
})
.catch(function(err) {
alert('Network error. Please try again.');
btn.disabled = false;
btn.innerHTML = '&lt;i class="fa fa-check-circle">&lt;/i> Complete Task';
btn.innerHTML = origHtml;
});
}).catch(function() {
alert('Location access is required. Please enable GPS and try again.');
btn.disabled = false;
btn.innerHTML = origHtml;
});
}
@@ -1184,10 +1384,16 @@
btns.forEach(function(b){b.disabled = true;});
try {
var coords = await window.fusionGetLocation();
var resp = await fetch('/my/technician/task/' + taskId + '/voice-complete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {transcription: text}})
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
transcription: text,
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
});
var data = await resp.json();
if (data.result &amp;&amp; data.result.success) {
@@ -1197,7 +1403,11 @@
btns.forEach(function(b){b.disabled = false;});
}
} catch (err) {
if (err instanceof GeolocationPositionError || err.code) {
alert('Location access is required. Please enable GPS and try again.');
} else {
alert('Error: ' + err.message);
}
btns.forEach(function(b){b.disabled = false;});
}
}
@@ -1271,15 +1481,15 @@
</t>
</div>
<div class="mt-2">
<strong><t t-out="task.partner_id.name or task.name"/></strong>
<strong><t t-out="task.client_display_name or task.name"/></strong>
</div>
<div class="text-muted small">
<i class="fa fa-map-marker me-1"/><t t-out="task.address_display or 'No address'"/>
</div>
<t t-if="task.partner_phone">
<t t-if="task.client_display_phone">
<div class="small mt-1">
<a t-attf-href="tel:#{task.partner_phone}" class="text-decoration-none">
<i class="fa fa-phone me-1"/><t t-out="task.partner_phone"/>
<a t-attf-href="tel:#{task.client_display_phone}" class="text-decoration-none">
<i class="fa fa-phone me-1"/><t t-out="task.client_display_phone"/>
</a>
</div>
</t>
@@ -1353,7 +1563,7 @@
<span t-attf-class="tech-badge tech-badge-#{task.task_type} me-1">
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
</span>
<t t-out="task.partner_id.name or task.name"/>
<t t-out="task.client_display_name or task.name"/>
</div>
<div class="tech-timeline-meta">
<i class="fa fa-map-marker me-1"/><t t-out="task.address_city or 'No address'"/>
@@ -1384,7 +1594,7 @@
<div class="container-fluid py-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3><i class="fa fa-map-marker"/> Technician Locations</h3>
<a href="/web#action=fusion_claims.action_technician_locations" class="btn btn-secondary btn-sm">
<a href="/web#action=fusion_tasks.action_technician_locations" class="btn btn-secondary btn-sm">
<i class="fa fa-list"/> View History
</a>
</div>

View File

@@ -189,6 +189,89 @@
</div>
</t>
<!-- My Schedule (All portal roles) -->
<div class="col-md-6">
<a href="/my/schedule" class="card h-100 border-0 shadow-sm text-decoration-none" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div class="rounded-circle d-flex align-items-center justify-content-center" t-attf-style="width: 50px; height: 50px; background: {{fc_gradient}};">
<i class="fa fa-calendar-check-o fa-lg text-white"/>
</div>
</div>
<div>
<h5 class="mb-1 text-dark">My Schedule</h5>
<small class="text-muted">View and book appointments</small>
</div>
</div>
</a>
</div>
<!-- Clock In/Out -->
<t t-if="clock_enabled">
<div class="col-md-6">
<style>
@keyframes hcPulseGreen {
0% { box-shadow: 0 0 0 0 rgba(16,185,129,0.5); }
70% { box-shadow: 0 0 0 14px rgba(16,185,129,0); }
100% { box-shadow: 0 0 0 0 rgba(16,185,129,0); }
}
@keyframes hcPulseRed {
0% { box-shadow: 0 0 0 0 rgba(239,68,68,0.5); }
70% { box-shadow: 0 0 0 14px rgba(239,68,68,0); }
100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
}
.hc-btn-ring {
width: 56px; height: 56px; border-radius: 50%; border: none;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; pointer-events: none;
}
.hc-btn-ring--in {
background: #10b981;
animation: hcPulseGreen 2s ease-in-out infinite;
}
.hc-btn-ring--out {
background: #ef4444;
animation: hcPulseRed 2s ease-in-out infinite;
}
.hc-btn-ring i { color: #fff; font-size: 1.4rem; }
.hc-btn-ring--in i { padding-left: 3px; }
.hc-timer-badge {
display: inline-block; font-family: monospace; font-size: 0.75rem; font-weight: 700;
color: #10b981; background: rgba(16,185,129,0.1); border-radius: 20px;
padding: 2px 10px; letter-spacing: 0.05em;
}
.hc-clock-link { text-decoration: none; }
.hc-clock-link:hover { text-decoration: none; }
.hc-clock-link:hover .card { box-shadow: 0 4px 16px rgba(0,0,0,0.12) !important; }
.hc-clock-link:active .hc-btn-ring { transform: scale(0.92); }
</style>
<a href="/my/clock" class="hc-clock-link"
id="homeClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''">
<div class="card h-100 border-0 shadow-sm" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div t-attf-class="hc-btn-ring #{clock_checked_in and 'hc-btn-ring--out' or 'hc-btn-ring--in'}">
<i t-attf-class="fa #{clock_checked_in and 'fa-stop' or 'fa-play'}"/>
</div>
</div>
<div style="min-width: 0;">
<h5 class="mb-0 text-dark" id="homeClockStatus">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Clock In</t>
</h5>
<div id="homeClockTimer">
<t t-if="clock_checked_in"><span class="hc-timer-badge">00:00:00</span></t>
<t t-else=""><small class="text-muted">Tap to start your shift</small></t>
</div>
</div>
</div>
</div>
</a>
</div>
</t>
<!-- Funding Claims (Clients/Authorizers) -->
<t t-if="request.env.user.partner_id.is_client_portal or request.env.user.partner_id.is_authorizer">
<div class="col-md-6">
@@ -251,6 +334,28 @@
</div>
</div>
</div>
<!-- Home Clock Timer (display only, links to /my/clock) -->
<script type="text/javascript">
(function() {
var card = document.getElementById('homeClockCard');
if (!card) return;
var isCheckedIn = card.dataset.checkedIn === 'true';
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
if (!isCheckedIn || !checkInTime) return;
function pad(n) { return n &lt; 10 ? '0' + n : '' + n; }
var badge = document.querySelector('#homeClockTimer .hc-timer-badge');
if (!badge) return;
function tick() {
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
badge.textContent = pad(Math.floor(diff / 3600)) + ':' + pad(Math.floor((diff % 3600) / 60)) + ':' + pad(diff % 60);
}
tick();
setInterval(tick, 1000);
})();
</script>
</t>
</xpath>
</template>
@@ -1087,6 +1192,41 @@
</div>
</div>
<!-- Clock In/Out -->
<t t-if="clock_enabled">
<div class="tech-clock-card mb-3"
id="techClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<div class="tech-clock-dot" t-att-class="'tech-clock-dot--active' if clock_checked_in else ''"/>
<div>
<div class="tech-clock-status" id="clockStatusText">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Not Clocked In</t>
</div>
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
</div>
</div>
<button class="tech-clock-btn" id="clockActionBtn"
t-att-class="'tech-clock-btn--out' if clock_checked_in else 'tech-clock-btn--in'"
onclick="handleClockAction()">
<t t-if="clock_checked_in">
<i class="fa fa-stop-circle-o"/> Clock Out
</t>
<t t-else="">
<i class="fa fa-play-circle-o"/> Clock In
</t>
</button>
</div>
<div class="tech-clock-error" id="clockError" style="display:none;">
<i class="fa fa-exclamation-triangle"/>
<span id="clockErrorText"/>
</div>
</div>
</t>
<!-- Stats Cards - 2x2 on mobile, 4 columns on desktop -->
<div class="row mb-3 g-2">
<div class="col-6 col-md-3">
@@ -1377,6 +1517,113 @@
<!-- Include loaner modals -->
<t t-call="fusion_authorizer_portal.loaner_checkout_modal"/>
<t t-call="fusion_authorizer_portal.loaner_return_modal"/>
<!-- Clock In/Out JS -->
<script type="text/javascript">
(function() {
var card = document.getElementById('techClockCard');
if (!card) return;
var isCheckedIn = card.dataset.checkedIn === 'true';
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
var timerInterval = null;
function updateTimer() {
if (!checkInTime) return;
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
var h = Math.floor(diff / 3600);
var m = Math.floor((diff % 3600) / 60);
var s = diff % 60;
var pad = function(n) { return n &lt; 10 ? '0' + n : '' + n; };
document.getElementById('clockTimer').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
}
function startTimer() { stopTimer(); updateTimer(); timerInterval = setInterval(updateTimer, 1000); }
function stopTimer() { if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } }
function applyState() {
var dot = card.querySelector('.tech-clock-dot');
var statusEl = document.getElementById('clockStatusText');
var btn = document.getElementById('clockActionBtn');
var timerEl = document.getElementById('clockTimer');
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
if (btn) {
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in');
btn.innerHTML = isCheckedIn
? '&lt;i class="fa fa-stop-circle-o">&lt;/i> Clock Out'
: '&lt;i class="fa fa-play-circle-o">&lt;/i> Clock In';
}
if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00';
}
if (isCheckedIn &amp;&amp; checkInTime) startTimer();
window.handleClockAction = function() {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
var errText = document.getElementById('clockErrorText');
btn.disabled = true;
errEl.style.display = 'none';
function doClockAction(lat, lng, acc) {
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
latitude: lat, longitude: lng, accuracy: acc, source: 'portal'
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var result = data.result || {};
if (result.error) {
errText.textContent = result.error;
errEl.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else {
isCheckedIn = false;
checkInTime = null;
stopTimer();
}
applyState();
btn.disabled = false;
})
.catch(function() {
errText.textContent = 'Network error. Please try again.';
errEl.style.display = 'flex';
btn.disabled = false;
});
}
if (window.fusionGetLocation) {
window.fusionGetLocation().then(function(coords) {
doClockAction(coords.latitude, coords.longitude, coords.accuracy);
}).catch(function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
});
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(pos) {
doClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy);
}, function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
}, {enableHighAccuracy: true, timeout: 15000});
} else {
doClockAction(0, 0, 0);
}
};
})();
</script>
</t>
</template>
@@ -3699,4 +3946,232 @@
</t>
</template>
<!-- ============================================================ -->
<!-- TASK-LEVEL POD SIGNATURE (works for shadow + regular tasks) -->
<!-- ============================================================ -->
<template id="portal_task_pod_signature" name="Task POD Signature Capture">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="False"/>
<t t-set="no_breadcrumbs" t-value="True"/>
<div class="container mt-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="/my/home">Home</a></li>
<li class="breadcrumb-item"><a href="/my/technician">Dashboard</a></li>
<li class="breadcrumb-item"><a href="/my/technician/tasks">Tasks</a></li>
<li class="breadcrumb-item"><a t-attf-href="/my/technician/task/#{task.id}"><t t-out="task.name"/></a></li>
<li class="breadcrumb-item active" aria-current="page">Collect POD Signature</li>
</ol>
</nav>
</div>
<div class="container py-4">
<div class="row mb-4">
<div class="col-12">
<h2>
<i class="fa fa-pencil-square-o me-2"/>
Proof of Delivery - <t t-out="task.name"/>
</h2>
</div>
</div>
<div class="row">
<div class="col-lg-7">
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fa fa-truck me-2"/>Delivery Summary</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>Client:</strong> <t t-out="task.client_display_name or 'N/A'"/></p>
<p><strong>Task:</strong> <t t-out="task.name"/></p>
<p><strong>Type:</strong>
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
</p>
</div>
<div class="col-md-6">
<p><strong>Delivery Address:</strong></p>
<p class="mb-0 text-muted">
<t t-out="task.address_display or 'No address'"/>
</p>
<t t-if="task.scheduled_date">
<p class="mt-2"><strong>Scheduled:</strong>
<t t-out="task.scheduled_date" t-options="{'widget': 'date'}"/>
<t t-out="task.time_start_display"/> - <t t-out="task.time_end_display"/>
</p>
</t>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card" id="task-pod-signature-section">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="fa fa-pencil me-2"/>Client Signature</h5>
</div>
<div class="card-body">
<t t-if="has_existing_signature">
<div class="alert alert-warning">
<i class="fa fa-exclamation-triangle me-2"/>
A signature has already been collected. Submitting a new one will replace it.
</div>
</t>
<form id="taskPodSignatureForm">
<div class="mb-3">
<label for="task_client_name" class="form-label">
Client Name <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="task_client_name"
name="client_name" required=""
t-att-value="task.pod_client_name or task.client_display_name or ''"
placeholder="Enter the client's full name"/>
</div>
<div class="mb-3">
<label for="task_signature_date" class="form-label">Signature Date</label>
<input type="date" class="form-control" id="task_signature_date" name="signature_date"/>
<script>document.getElementById('task_signature_date').value = new Date().toISOString().slice(0,10);</script>
</div>
<div class="mb-3">
<label class="form-label">Signature <span class="text-danger">*</span></label>
<div class="border rounded p-2 bg-white">
<canvas id="task-signature-canvas"
style="width:100%;height:200px;border:1px dashed #ccc;border-radius:4px;touch-action:none;">
</canvas>
</div>
<div class="d-flex justify-content-between mt-2">
<small class="text-muted">Draw signature above</small>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="clearTaskSignature()">
<i class="fa fa-eraser me-1"/>Clear
</button>
</div>
</div>
<div class="d-grid gap-2">
<button type="button" class="btn btn-success btn-lg"
onclick="submitTaskPODSignature()">
<i class="fa fa-check me-2"/>Submit Signature
</button>
<a t-attf-href="/my/technician/task/#{task.id}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<style>
.tpod-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,0.65); backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);
z-index:9999; align-items:center; justify-content:center; }
.tpod-overlay.show { display:flex; }
.tpod-overlay-card { background:#fff; border-radius:20px; padding:2.5rem 2rem;
max-width:420px; width:90%; text-align:center; animation:tpodSlideUp 0.3s ease;
box-shadow:0 8px 32px rgba(0,0,0,0.2); }
.tpod-overlay-icon { font-size:3.5rem; margin-bottom:1rem; }
@keyframes tpodSlideUp { from { opacity:0; transform:translateY(30px); } to { opacity:1; transform:translateY(0); } }
</style>
<div id="taskPodOverlay" class="tpod-overlay">
<div class="tpod-overlay-card">
<div class="tpod-overlay-icon" id="tpodIcon"></div>
<h4 id="tpodTitle" style="font-weight:700;"></h4>
<p id="tpodMsg" style="color:#6c757d;margin-bottom:1.5rem;"></p>
<div id="tpodActions"></div>
</div>
</div>
<script type="text/javascript">
var tCanvas, tCtx, tIsDrawing = false;
function showTaskPodOverlay(type, title, msg, url) {
var ov = document.getElementById('taskPodOverlay');
document.getElementById('tpodIcon').innerHTML = type === 'success'
? '&lt;i class="fa fa-check-circle text-success">&lt;/i>'
: '&lt;i class="fa fa-exclamation-circle text-danger">&lt;/i>';
document.getElementById('tpodTitle').textContent = title;
document.getElementById('tpodTitle').className = type === 'success' ? 'text-success' : 'text-danger';
document.getElementById('tpodMsg').textContent = msg;
var acts = document.getElementById('tpodActions');
if (type === 'success' &amp;&amp; url) {
acts.innerHTML = '&lt;a href="' + url + '" class="btn btn-success w-100 rounded-pill mb-2">Continue&lt;/a>' +
'&lt;p class="text-muted small mb-0">Redirecting in &lt;span id="tpodCD">3&lt;/span>s...&lt;/p>';
ov.classList.add('show');
var s = 3, t = setInterval(function() { s--; var c = document.getElementById('tpodCD');
if (c) c.textContent = s; if (s &lt;= 0) { clearInterval(t); window.location.href = url; } }, 1000);
} else {
acts.innerHTML = '&lt;button class="btn btn-outline-secondary w-100 rounded-pill" onclick="document.getElementById(\'taskPodOverlay\').classList.remove(\'show\')">OK&lt;/button>';
ov.classList.add('show');
}
}
document.addEventListener('DOMContentLoaded', function() {
tCanvas = document.getElementById('task-signature-canvas');
if (!tCanvas) return;
tCtx = tCanvas.getContext('2d');
var r = tCanvas.getBoundingClientRect();
tCanvas.width = r.width * 2; tCanvas.height = r.height * 2;
tCtx.scale(2, 2); tCtx.lineCap = 'round'; tCtx.lineJoin = 'round';
tCtx.lineWidth = 2; tCtx.strokeStyle = '#000';
tCanvas.addEventListener('mousedown', tStart);
tCanvas.addEventListener('mousemove', tDraw);
tCanvas.addEventListener('mouseup', tStop);
tCanvas.addEventListener('mouseout', tStop);
tCanvas.addEventListener('touchstart', function(e) { e.preventDefault(); tStart(e); }, {passive:false});
tCanvas.addEventListener('touchmove', function(e) { e.preventDefault(); tDraw(e); }, {passive:false});
tCanvas.addEventListener('touchend', tStop);
var sec = document.getElementById('task-pod-signature-section');
if (sec) setTimeout(function() { sec.scrollIntoView({behavior:'smooth', block:'start'}); }, 300);
});
function tPos(e) {
var r = tCanvas.getBoundingClientRect();
if (e.touches) return { x: e.touches[0].clientX - r.left, y: e.touches[0].clientY - r.top };
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function tStart(e) { tIsDrawing = true; var p = tPos(e); tCtx.beginPath(); tCtx.moveTo(p.x, p.y); }
function tDraw(e) { if (!tIsDrawing) return; var p = tPos(e); tCtx.lineTo(p.x, p.y); tCtx.stroke(); }
function tStop() { tIsDrawing = false; }
function clearTaskSignature() { if (tCtx) tCtx.clearRect(0, 0, tCanvas.width, tCanvas.height); }
function submitTaskPODSignature() {
var name = document.getElementById('task_client_name').value.trim();
var sigDate = document.getElementById('task_signature_date').value;
if (!name) { showTaskPodOverlay('error', 'Missing Information', 'Please enter the client name.'); return; }
var blank = document.createElement('canvas');
blank.width = tCanvas.width; blank.height = tCanvas.height;
if (tCanvas.toDataURL() === blank.toDataURL()) {
showTaskPodOverlay('error', 'Missing Signature', 'Please draw a signature before submitting.'); return;
}
var sigData = tCanvas.toDataURL('image/png');
var btn = document.querySelector('button[onclick="submitTaskPODSignature()"]');
var orig = btn.innerHTML; btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin me-2">&lt;/i>Saving...'; btn.disabled = true;
fetch('<t t-out="'/my/technician/task/' + str(task.id) + '/pod/sign'"/>', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ jsonrpc:'2.0', method:'call',
params: { client_name: name, signature_data: sigData, signature_date: sigDate || null },
id: Math.floor(Math.random()*1000000) })
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.result &amp;&amp; d.result.success) {
showTaskPodOverlay('success', 'Signature Saved!', 'Proof of Delivery recorded.', d.result.redirect_url);
} else {
showTaskPodOverlay('error', 'Error', d.result?.error || 'Unknown error');
btn.innerHTML = orig; btn.disabled = false;
}
}).catch(function() {
showTaskPodOverlay('error', 'Connection Error', 'Please check your connection.');
btn.innerHTML = orig; btn.disabled = false;
});
}
</script>
</t>
</template>
</odoo>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.6.0.0',
'version': '19.0.7.2.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -84,6 +84,7 @@
'calendar',
'ai',
'fusion_ringcentral',
'fusion_tasks',
],
'external_dependencies': {
'python': ['pdf2image', 'PIL'],
@@ -127,47 +128,38 @@
'wizard/odsp_submit_to_odsp_wizard_views.xml',
'wizard/odsp_pre_approved_wizard_views.xml',
'wizard/odsp_ready_delivery_wizard_views.xml',
'wizard/ltc_repair_create_so_wizard_views.xml',
'wizard/send_page11_wizard_views.xml',
'views/res_partner_views.xml',
'views/pdf_template_inherit_views.xml',
'views/dashboard_views.xml',
'views/client_profile_views.xml',
'wizard/xml_import_wizard_views.xml',
'views/ltc_facility_views.xml',
'views/ltc_repair_views.xml',
'views/ltc_cleanup_views.xml',
'views/ltc_form_submission_views.xml',
'views/adp_claims_views.xml',
'views/submission_history_views.xml',
'views/fusion_loaner_views.xml',
'views/page11_sign_request_views.xml',
'views/technician_task_views.xml',
'views/task_sync_views.xml',
'views/technician_location_views.xml',
'report/report_actions.xml',
'report/report_templates.xml',
'report/sale_report_portrait.xml',
'report/sale_report_landscape.xml',
'report/sale_report_ltc_repair.xml',
'report/invoice_report_portrait.xml',
'report/invoice_report_landscape.xml',
'report/report_proof_of_delivery.xml',
'report/report_proof_of_delivery_standard.xml',
'report/report_proof_of_pickup.xml',
'report/report_rental_agreement.xml',
'report/report_approved_items.xml',
'report/report_grab_bar_waiver.xml',
'report/report_accessibility_contract.xml',
'report/report_mod_quotation.xml',
'report/report_mod_invoice.xml',
'data/ltc_data.xml',
'report/report_ltc_nursing_station.xml',
'data/ltc_report_data.xml',
'data/mail_template_data.xml',
'data/ai_agent_data.xml',
],
'assets': {
'web.assets_backend': [
'fusion_claims/static/src/scss/fusion_claims.scss',
'fusion_claims/static/src/css/fusion_task_map_view.scss',
'fusion_claims/static/src/js/chatter_resize.js',
'fusion_claims/static/src/js/document_preview.js',
'fusion_claims/static/src/js/preview_button_widget.js',
@@ -176,10 +168,9 @@
'fusion_claims/static/src/js/tax_totals_patch.js',
'fusion_claims/static/src/js/google_address_autocomplete.js',
'fusion_claims/static/src/js/calendar_store_hours.js',
'fusion_claims/static/src/js/fusion_task_map_view.js',
'fusion_claims/static/src/js/attachment_image_compress.js',
'fusion_claims/static/src/js/debug_required_fields.js',
'fusion_claims/static/src/xml/document_preview.xml',
'fusion_claims/static/src/xml/fusion_task_map_view.xml',
],
},
'images': ['static/description/icon.png'],

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Server Action: Sync ADP Fields to Invoices -->
<!-- This appears in the Action menu on Sale Orders -->
<record id="action_sync_adp_fields_server" model="ir.actions.server">
<field name="name">Sync to Invoices</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_view_types">form,list</field>
<field name="state">code</field>
<field name="code">
if records:
# Filter to only ADP sales
adp_records = records.filtered(lambda r: r.x_fc_is_adp_sale and r.state == 'sale')
if adp_records:
action = adp_records.action_sync_adp_fields()
else:
action = {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'No ADP Sales',
'message': 'Selected orders are not confirmed ADP sales.',
'type': 'warning',
'sticky': False,
}
}
</field>
</record>
<data/>
</odoo>

View File

@@ -45,26 +45,6 @@
<field name="value">True</field>
</record>
<!-- Technician / Field Service -->
<record id="config_store_open_hour" model="ir.config_parameter">
<field name="key">fusion_claims.store_open_hour</field>
<field name="value">9.0</field>
</record>
<record id="config_store_close_hour" model="ir.config_parameter">
<field name="key">fusion_claims.store_close_hour</field>
<field name="value">18.0</field>
</record>
<!-- Push Notifications -->
<record id="config_push_enabled" model="ir.config_parameter">
<field name="key">fusion_claims.push_enabled</field>
<field name="value">False</field>
</record>
<record id="config_push_advance_minutes" model="ir.config_parameter">
<field name="key">fusion_claims.push_advance_minutes</field>
<field name="value">30</field>
</record>
<!-- Field Mappings (defaults for fresh installs) -->
<record id="config_field_sale_type" model="ir.config_parameter">
<field name="key">fusion_claims.field_sale_type</field>
@@ -147,17 +127,5 @@
<field name="value">1-888-222-5099</field>
</record>
<!-- Cross-instance task sync: unique ID for this Odoo instance -->
<record id="config_sync_instance_id" model="ir.config_parameter">
<field name="key">fusion_claims.sync_instance_id</field>
<field name="value"></field>
</record>
<!-- LTC Portal Form Password -->
<record id="config_ltc_form_password" model="ir.config_parameter">
<field name="key">fusion_claims.ltc_form_password</field>
<field name="value"></field>
</record>
</data>
</odoo>

View File

@@ -6,16 +6,6 @@
-->
<odoo>
<data>
<!-- Cron Job: Sync ADP Fields from Sale Orders to Invoices -->
<record id="ir_cron_sync_adp_fields" model="ir.cron">
<field name="name">Fusion Claims: Sync ADP Fields</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">model._cron_sync_adp_fields()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
</record>
<!-- Cron Job: Renew ADP Delivery Reminders -->
<record id="ir_cron_renew_delivery_reminders" model="ir.cron">
<field name="name">Fusion Claims: Renew Delivery Reminders</field>
@@ -134,50 +124,17 @@
<field name="nextcall" eval="DateTime.now().replace(hour=10, minute=0, second=0)"/>
</record>
<!-- Cron Job: Calculate Travel Times for Technician Tasks -->
<record id="ir_cron_technician_travel_times" model="ir.cron">
<field name="name">Fusion Claims: Calculate Technician Travel Times</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<!-- Cron Job: Expire Old Page 11 Signing Requests -->
<record id="ir_cron_expire_page11_requests" model="ir.cron">
<field name="name">Fusion Claims: Expire Page 11 Signing Requests</field>
<field name="model_id" ref="model_fusion_page11_sign_request"/>
<field name="state">code</field>
<field name="code">model._cron_calculate_travel_times()</field>
<field name="code">model._cron_expire_requests()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=5, minute=0, second=0)"/>
<field name="nextcall" eval="DateTime.now().replace(hour=2, minute=0, second=0)"/>
</record>
<!-- Cron Job: Send Push Notifications for Upcoming Tasks -->
<record id="ir_cron_technician_push_notifications" model="ir.cron">
<field name="name">Fusion Claims: Technician Push Notifications</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="state">code</field>
<field name="code">model._cron_send_push_notifications()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Pull Remote Technician Tasks (cross-instance sync) -->
<record id="ir_cron_task_sync_pull" model="ir.cron">
<field name="name">Fusion Claims: Sync Remote Tasks (Pull)</field>
<field name="model_id" ref="model_fusion_task_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_pull_remote_tasks()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Cleanup Old Shadow Tasks (30+ days) -->
<record id="ir_cron_task_sync_cleanup" model="ir.cron">
<field name="name">Fusion Claims: Cleanup Old Shadow Tasks</field>
<field name="model_id" ref="model_fusion_task_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_cleanup_old_shadows()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
</record>
</data>
</odoo>

View File

@@ -20,34 +20,34 @@
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<div style="padding:32px 28px;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">ADP Quotation</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Please find attached your quotation <strong style="color:#2d3748;"><t t-out="object.name"/></strong>.
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">ADP Quotation</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Please find attached your quotation <strong><t t-out="object.name"/></strong>.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Quotation Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Reference</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.date_order" t-options="{'widget': 'date'}"/></td></tr>
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Quotation Details</td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Reference</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Date</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.date_order" t-options="{'widget': 'date'}"/></td></tr>
<t t-if="object.x_fc_authorizer_id">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Authorizer</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_authorizer_id.name"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Authorizer</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_authorizer_id.name"/></td></tr>
</t>
<t t-if="object.x_fc_client_type == 'REG'">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Client Portion (25%)</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_client_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">ADP Portion (75%)</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_adp_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Client Portion (25%)</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_client_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">ADP Portion (75%)</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_adp_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</t>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Total</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;font-weight:600;border-top:2px solid rgba(128,128,128,0.25);">Total</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid rgba(128,128,128,0.25);"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</table>
<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;color:#718096;"><strong style="color:#2d3748;">Attached:</strong> ADP Quotation (PDF)</p>
<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;opacity:0.65;"><strong style="opacity:1;">Attached:</strong> ADP Quotation (PDF)</p>
</div>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Please review the attached quotation. If you have any questions or need assistance, do not hesitate to contact us.</p>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">Please review the attached quotation. If you have any questions or need assistance, do not hesitate to contact us.</p>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
@@ -70,34 +70,34 @@
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#38a169;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<div style="padding:32px 28px;">
<p style="color:#38a169;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Order Confirmed</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Your ADP sales order <strong style="color:#2d3748;"><t t-out="object.name"/></strong> has been confirmed.
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Order Confirmed</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Your ADP sales order <strong><t t-out="object.name"/></strong> has been confirmed.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Order Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Reference</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.date_order" t-options="{'widget': 'date'}"/></td></tr>
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Order Details</td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Reference</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Date</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.date_order" t-options="{'widget': 'date'}"/></td></tr>
<t t-if="object.x_fc_authorizer_id">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Authorizer</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_authorizer_id.name"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Authorizer</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_authorizer_id.name"/></td></tr>
</t>
<t t-if="object.x_fc_client_type == 'REG'">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Client Portion (25%)</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_client_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">ADP Portion (75%)</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_adp_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Client Portion (25%)</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_client_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">ADP Portion (75%)</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_adp_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</t>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Total</td><td style="padding:10px 14px;color:#38a169;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;font-weight:600;border-top:2px solid rgba(128,128,128,0.25);">Total</td><td style="padding:10px 14px;color:#38a169;font-size:14px;font-weight:700;border-top:2px solid rgba(128,128,128,0.25);"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</table>
<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;color:#718096;"><strong style="color:#2d3748;">Attached:</strong> Sales Order Confirmation (PDF)</p>
<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;opacity:0.65;"><strong style="opacity:1;">Attached:</strong> Sales Order Confirmation (PDF)</p>
</div>
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Your order is being processed. We will keep you updated on the delivery status and any updates from the Assistive Devices Program.</p>
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">Your order is being processed. We will keep you updated on the delivery status and any updates from the Assistive Devices Program.</p>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
@@ -120,42 +120,42 @@
<field name="email_from">{{ (object.invoice_user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<div style="padding:32px 28px;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Invoice</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Please find attached your invoice <strong style="color:#2d3748;"><t t-out="object.name or 'Draft'"/></strong>.
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Invoice</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Please find attached your invoice <strong><t t-out="object.name or 'Draft'"/></strong>.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Invoice Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Invoice</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name or 'Draft'"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.invoice_date" t-options="{'widget': 'date'}"/></td></tr>
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Invoice Details</td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Invoice</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name or 'Draft'"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Date</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.invoice_date" t-options="{'widget': 'date'}"/></td></tr>
<t t-if="object.invoice_date_due">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Due Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.invoice_date_due" t-options="{'widget': 'date'}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Due Date</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.invoice_date_due" t-options="{'widget': 'date'}"/></td></tr>
</t>
<t t-if="object.x_fc_adp_invoice_portion">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Type</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Type</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">
<t t-if="object.x_fc_adp_invoice_portion == 'client'">Client Portion</t>
<t t-if="object.x_fc_adp_invoice_portion == 'adp'">ADP Portion</t>
</td></tr>
</t>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Amount Due</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.amount_residual" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;font-weight:600;border-top:2px solid rgba(128,128,128,0.25);">Amount Due</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid rgba(128,128,128,0.25);"><t t-out="object.amount_residual" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</table>
<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;color:#718096;"><strong style="color:#2d3748;">Attached:</strong> Invoice (PDF)</p>
<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;opacity:0.65;"><strong style="opacity:1;">Attached:</strong> Invoice (PDF)</p>
</div>
<t t-if="object.x_fc_adp_invoice_portion == 'client'">
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">This invoice represents your client portion for the ADP-funded equipment. The remaining amount will be billed directly to the Assistive Devices Program.</p>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">This invoice represents your client portion for the ADP-funded equipment. The remaining amount will be billed directly to the Assistive Devices Program.</p>
</div>
</t>
<t t-else="">
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Please review the attached invoice and process payment at your earliest convenience. Contact us if you have any questions.</p>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">Please review the attached invoice and process payment at your earliest convenience. Contact us if you have any questions.</p>
</div>
</t>
<t t-set="sig" t-value="object.invoice_user_id.signature or object.user_id.signature"/>

View File

@@ -9,7 +9,7 @@
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
<field name="taxes_id" eval="[(6, 0, [ref('account.1_hst_sale_tax_13')])]"/>
<!-- taxes_id set via post_init_hook or manually per database -->
</record>
</data>
</odoo>

View File

@@ -3,7 +3,6 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
from . import email_builder_mixin
from . import adp_posting_schedule
from . import res_company
from . import res_config_settings
@@ -27,12 +26,5 @@ from . import client_chat
from . import ai_agent_ext
from . import dashboard
from . import res_partner
from . import res_users
from . import technician_task
from . import task_sync
from . import technician_location
from . import push_subscription
from . import ltc_facility
from . import ltc_repair
from . import ltc_cleanup
from . import ltc_form_submission
from . import page11_sign_request

View File

@@ -105,9 +105,11 @@ class AccountMove(models.Model):
try:
report = self.env.ref('fusion_claims.action_report_mod_invoice')
pdf_content, _ = report._render_qweb_pdf(report.id, [self.id])
client_name = (so.partner_id.name or 'Client').replace(' ', '_').replace(',', '')
name_parts = (so.partner_id.name or 'Client').strip().split()
first = name_parts[0] if name_parts else 'Client'
last = name_parts[-1] if len(name_parts) > 1 else ''
att = Attachment.create({
'name': f'Invoice - {client_name} - {self.name}.pdf',
'name': f'{first}_{last}_MOD_Invoice_{self.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'account.move',

View File

@@ -57,6 +57,12 @@ class FusionADPDeviceCode(models.Model):
index=True,
help='Device manufacturer',
)
build_type = fields.Selection(
[('modular', 'Modular'), ('custom_fabricated', 'Custom Fabricated')],
string='Build Type',
index=True,
help='Build type for positioning/seating devices: Modular or Custom Fabricated',
)
device_description = fields.Char(
string='Device Description',
help='Detailed device description from mobility manual',
@@ -243,6 +249,16 @@ class FusionADPDeviceCode(models.Model):
manufacturer = self._clean_text(item.get('Manufacturer', '') or item.get('manufacturer', ''))
device_description = self._clean_text(item.get('Device Description', '') or item.get('device_description', ''))
# Parse build type (Modular / Custom Fabricated)
build_type_raw = self._clean_text(item.get('Build Type', '') or item.get('build_type', ''))
build_type = False
if build_type_raw:
bt_lower = build_type_raw.lower().strip()
if bt_lower in ('modular', 'mod'):
build_type = 'modular'
elif bt_lower in ('custom fabricated', 'custom_fabricated', 'custom'):
build_type = 'custom_fabricated'
# Parse quantity
qty_val = item.get('Quantity', 1) or item.get('Qty', 1) or item.get('quantity', 1)
max_qty = int(qty_val) if qty_val else 1
@@ -277,6 +293,8 @@ class FusionADPDeviceCode(models.Model):
'last_updated': fields.Datetime.now(),
'active': True,
}
if build_type:
vals['build_type'] = build_type
if existing:
existing.write(vals)

View File

@@ -0,0 +1,389 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import logging
import uuid
from datetime import timedelta
from markupsafe import Markup
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
SIGNER_TYPE_SELECTION = [
('client', 'Client (Self)'),
('spouse', 'Spouse'),
('parent', 'Parent'),
('legal_guardian', 'Legal Guardian'),
('poa', 'Power of Attorney'),
('public_trustee', 'Public Trustee'),
]
SIGNER_TYPE_TO_RELATIONSHIP = {
'spouse': 'Spouse',
'parent': 'Parent',
'legal_guardian': 'Legal Guardian',
'poa': 'Power of Attorney',
'public_trustee': 'Public Trustee',
}
class Page11SignRequest(models.Model):
_name = 'fusion.page11.sign.request'
_description = 'ADP Page 11 Remote Signing Request'
_inherit = ['fusion.email.builder.mixin']
_order = 'create_date desc'
sale_order_id = fields.Many2one(
'sale.order', string='Sale Order',
required=True, ondelete='cascade', index=True,
)
access_token = fields.Char(
string='Access Token', required=True, copy=False,
default=lambda self: str(uuid.uuid4()), index=True,
)
state = fields.Selection([
('draft', 'Draft'),
('sent', 'Sent'),
('signed', 'Signed'),
('expired', 'Expired'),
('cancelled', 'Cancelled'),
], string='Status', default='draft', required=True, tracking=True)
signer_email = fields.Char(string='Recipient Email', required=True)
signer_type = fields.Selection(
SIGNER_TYPE_SELECTION, string='Signer Type',
default='client', required=True,
)
signer_name = fields.Char(string='Signer Name')
signer_relationship = fields.Char(string='Relationship to Client')
signature_data = fields.Binary(string='Signature', attachment=True)
signed_pdf = fields.Binary(string='Signed PDF', attachment=True)
signed_pdf_filename = fields.Char(string='Signed PDF Filename')
signed_date = fields.Datetime(string='Signed Date')
sent_date = fields.Datetime(string='Sent Date')
expiry_date = fields.Datetime(string='Expiry Date')
consent_declaration_accepted = fields.Boolean(string='Declaration Accepted')
consent_signed_by = fields.Selection([
('applicant', 'Applicant'),
('agent', 'Agent'),
], string='Signed By')
client_first_name = fields.Char(string='Client First Name')
client_last_name = fields.Char(string='Client Last Name')
client_health_card = fields.Char(string='Health Card Number')
client_health_card_version = fields.Char(string='Health Card Version')
agent_first_name = fields.Char(string='Agent First Name')
agent_last_name = fields.Char(string='Agent Last Name')
agent_middle_initial = fields.Char(string='Agent Middle Initial')
agent_phone = fields.Char(string='Agent Phone')
agent_unit = fields.Char(string='Agent Unit Number')
agent_street_number = fields.Char(string='Agent Street Number')
agent_street = fields.Char(string='Agent Street Name')
agent_city = fields.Char(string='Agent City')
agent_province = fields.Char(string='Agent Province', default='Ontario')
agent_postal_code = fields.Char(string='Agent Postal Code')
custom_message = fields.Text(string='Custom Message')
company_id = fields.Many2one(
'res.company', string='Company',
related='sale_order_id.company_id', store=True,
)
def name_get(self):
return [
(r.id, f"Page 11 - {r.sale_order_id.name} ({r.state})")
for r in self
]
def _send_signing_email(self):
"""Build and send the signing request email."""
self.ensure_one()
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
sign_url = f'{base_url}/page11/sign/{self.access_token}'
order = self.sale_order_id
client_name = order.partner_id.name or 'N/A'
sections = [
('Case Details', [
('Client', client_name),
('Case Reference', order.name),
]),
]
if order.x_fc_authorizer_id:
sections[0][1].append(('Authorizer', order.x_fc_authorizer_id.name))
if order.x_fc_assessment_start_date:
sections[0][1].append((
'Assessment Date',
order.x_fc_assessment_start_date.strftime('%B %d, %Y'),
))
note_parts = []
if self.custom_message:
note_parts.append(self.custom_message)
days_left = 7
if self.expiry_date:
delta = self.expiry_date - fields.Datetime.now()
days_left = max(1, delta.days)
note_parts.append(
f'This link will expire in {days_left} days. '
'Please complete the signing at your earliest convenience.'
)
note_text = '<br/><br/>'.join(note_parts)
body_html = self._email_build(
title='Page 11 Signature Required',
summary=(
f'{order.company_id.name} requires your signature on the '
f'ADP Consent and Declaration form for <strong>{client_name}</strong>.'
),
sections=sections,
note=note_text,
email_type='info',
button_url=sign_url,
button_text='Sign Now',
sender_name=self.env.user.name,
)
mail_values = {
'subject': f'{order.company_id.name} - Page 11 Signature Required ({order.name})',
'body_html': body_html,
'email_to': self.signer_email,
'email_from': (
self.env.user.email_formatted
or order.company_id.email_formatted
),
'auto_delete': True,
}
mail = self.env['mail.mail'].sudo().create(mail_values)
mail.send()
self.write({
'state': 'sent',
'sent_date': fields.Datetime.now(),
})
signer_display = self.signer_name or self.signer_email
order.message_post(
body=Markup(
'Page 11 signing request sent to <strong>%s</strong> (%s).'
) % (signer_display, self.signer_email),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
def _generate_signed_pdf(self):
"""Generate the signed Page 11 PDF using the PDF template engine."""
self.ensure_one()
order = self.sale_order_id
assessment = self.env['fusion.assessment'].search([
('sale_order_id', '=', order.id),
], limit=1, order='create_date desc')
if assessment:
ctx = assessment._get_pdf_context()
else:
ctx = self._build_pdf_context_from_order()
if self.client_first_name:
ctx['client_first_name'] = self.client_first_name
if self.client_last_name:
ctx['client_last_name'] = self.client_last_name
if self.client_health_card:
ctx['client_health_card'] = self.client_health_card
if self.client_health_card_version:
ctx['client_health_card_version'] = self.client_health_card_version
ctx.update({
'consent_signed_by': self.consent_signed_by or '',
'consent_applicant': self.consent_signed_by == 'applicant',
'consent_agent': self.consent_signed_by == 'agent',
'consent_declaration_accepted': self.consent_declaration_accepted,
'consent_date': str(fields.Date.today()),
})
if self.consent_signed_by == 'agent':
ctx.update({
'agent_first_name': self.agent_first_name or '',
'agent_last_name': self.agent_last_name or '',
'agent_middle_initial': self.agent_middle_initial or '',
'agent_unit': self.agent_unit or '',
'agent_street_number': self.agent_street_number or '',
'agent_street_name': self.agent_street or '',
'agent_city': self.agent_city or '',
'agent_province': self.agent_province or '',
'agent_postal_code': self.agent_postal_code or '',
'agent_home_phone': self.agent_phone or '',
'agent_relationship': self.signer_relationship or '',
'agent_rel_spouse': self.signer_type == 'spouse',
'agent_rel_parent': self.signer_type == 'parent',
'agent_rel_poa': self.signer_type == 'poa',
'agent_rel_guardian': self.signer_type in ('legal_guardian', 'public_trustee'),
})
signatures = {}
if self.signature_data:
signatures['signature_page_11'] = base64.b64decode(self.signature_data)
template = self.env['fusion.pdf.template'].search([
('state', '=', 'active'),
('name', 'ilike', 'adp_page_11'),
], limit=1)
if not template:
template = self.env['fusion.pdf.template'].search([
('state', '=', 'active'),
('name', 'ilike', 'page 11'),
], limit=1)
if not template:
_logger.warning("No active PDF template found for Page 11")
return None
try:
pdf_bytes = template.generate_filled_pdf(ctx, signatures)
if pdf_bytes:
first, last = order._get_client_name_parts()
filename = f'{first}_{last}_Page11_Signed.pdf'
self.write({
'signed_pdf': base64.b64encode(pdf_bytes),
'signed_pdf_filename': filename,
})
return pdf_bytes
except Exception as e:
_logger.error("Failed to generate Page 11 PDF: %s", e)
return None
def _build_pdf_context_from_order(self):
"""Build a PDF context dict from the sale order when no assessment exists."""
order = self.sale_order_id
partner = order.partner_id
first, last = order._get_client_name_parts()
return {
'client_first_name': first,
'client_last_name': last,
'client_name': partner.name or '',
'client_street': partner.street or '',
'client_city': partner.city or '',
'client_state': partner.state_id.name if partner.state_id else 'Ontario',
'client_postal_code': partner.zip or '',
'client_phone': partner.phone or partner.mobile or '',
'client_email': partner.email or '',
'client_type': order.x_fc_client_type or '',
'client_type_reg': order.x_fc_client_type == 'REG',
'client_type_ods': order.x_fc_client_type == 'ODS',
'client_type_acs': order.x_fc_client_type == 'ACS',
'client_type_owp': order.x_fc_client_type == 'OWP',
'reference': order.name or '',
'authorizer_name': order.x_fc_authorizer_id.name if order.x_fc_authorizer_id else '',
'authorizer_phone': order.x_fc_authorizer_id.phone if order.x_fc_authorizer_id else '',
'authorizer_email': order.x_fc_authorizer_id.email if order.x_fc_authorizer_id else '',
'claim_authorization_date': str(order.x_fc_claim_authorization_date) if order.x_fc_claim_authorization_date else '',
'assessment_start_date': str(order.x_fc_assessment_start_date) if order.x_fc_assessment_start_date else '',
'assessment_end_date': str(order.x_fc_assessment_end_date) if order.x_fc_assessment_end_date else '',
}
def _update_sale_order(self):
"""Copy signing data from this request to the sale order."""
self.ensure_one()
order = self.sale_order_id
vals = {
'x_fc_page11_signer_type': self.signer_type,
'x_fc_page11_signer_name': self.signer_name,
'x_fc_page11_signed_date': fields.Date.today(),
}
if self.signer_type != 'client':
vals['x_fc_page11_signer_relationship'] = (
self.signer_relationship
or SIGNER_TYPE_TO_RELATIONSHIP.get(self.signer_type, '')
)
if self.signed_pdf:
vals['x_fc_signed_pages_11_12'] = self.signed_pdf
vals['x_fc_signed_pages_filename'] = self.signed_pdf_filename
order.with_context(
skip_page11_check=True,
skip_document_chatter=True,
).write(vals)
signer_display = self.signer_name or 'N/A'
if self.signed_pdf:
att = self.env['ir.attachment'].sudo().create({
'name': self.signed_pdf_filename or 'Page11_Signed.pdf',
'datas': self.signed_pdf,
'res_model': 'sale.order',
'res_id': order.id,
'mimetype': 'application/pdf',
})
order.message_post(
body=Markup(
'Page 11 has been signed by <strong>%s</strong> (%s).'
) % (signer_display, self.signer_email),
attachment_ids=[att.id],
message_type='notification',
subtype_xmlid='mail.mt_note',
)
else:
order.message_post(
body=Markup(
'Page 11 has been signed by <strong>%s</strong> (%s). '
'PDF generation was not available.'
) % (signer_display, self.signer_email),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
def action_cancel(self):
"""Cancel a pending signing request."""
for rec in self:
if rec.state in ('draft', 'sent'):
rec.state = 'cancelled'
def action_resend(self):
"""Resend the signing email."""
for rec in self:
if rec.state in ('sent', 'expired'):
rec.expiry_date = fields.Datetime.now() + timedelta(days=7)
rec.access_token = str(uuid.uuid4())
rec._send_signing_email()
def action_request_new_signature(self):
"""Create a new signing request (e.g. to re-sign after corrections)."""
self.ensure_one()
if self.state == 'signed':
self.state = 'cancelled'
return {
'type': 'ir.actions.act_window',
'name': 'Request Page 11 Signature',
'res_model': 'fusion_claims.send.page11.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_sale_order_id': self.sale_order_id.id,
'default_signer_email': self.signer_email,
'default_signer_name': self.signer_name,
'default_signer_type': self.signer_type,
},
}
@api.model
def _cron_expire_requests(self):
"""Mark expired unsigned requests."""
expired = self.search([
('state', '=', 'sent'),
('expiry_date', '<', fields.Datetime.now()),
])
if expired:
expired.write({'state': 'expired'})
_logger.info("Expired %d Page 11 signing requests", len(expired))

View File

@@ -61,6 +61,61 @@ class ProductTemplate(models.Model):
help='Rental price per month if loaner converts to rental',
)
# ==========================================================================
# LOANER EQUIPMENT FIELDS
# ==========================================================================
x_fc_equipment_type = fields.Selection([
('type_1_walker', 'Type 1 Walker'),
('type_2_mw', 'Type 2 MW'),
('type_2_pw', 'Type 2 PW'),
('type_2_walker', 'Type 2 Walker'),
('type_3_mw', 'Type 3 MW'),
('type_3_pw', 'Type 3 PW'),
('type_3_walker', 'Type 3 Walker'),
('type_4_mw', 'Type 4 MW'),
('type_5_mw', 'Type 5 MW'),
('ceiling_lift', 'Ceiling Lift'),
('mobility_scooter', 'Mobility Scooter'),
('patient_lift', 'Patient Lift'),
('transport_wheelchair', 'Transport Wheelchair'),
('standard_wheelchair', 'Standard Wheelchair'),
('power_wheelchair', 'Power Wheelchair'),
('cushion', 'Cushion'),
('backrest', 'Backrest'),
('stairlift', 'Stairlift'),
('others', 'Others'),
], string='Equipment Type')
x_fc_wheelchair_category = fields.Selection([
('type_1', 'Type 1'),
('type_2', 'Type 2'),
('type_3', 'Type 3'),
('type_4', 'Type 4'),
('type_5', 'Type 5'),
], string='Wheelchair Category')
x_fc_seat_width = fields.Char(string='Seat Width')
x_fc_seat_depth = fields.Char(string='Seat Depth')
x_fc_seat_height = fields.Char(string='Seat Height')
x_fc_storage_location = fields.Selection([
('warehouse', 'Warehouse'),
('westin_brampton', 'Westin Brampton'),
('mobility_etobicoke', 'Mobility Etobicoke'),
('scarborough_storage', 'Scarborough Storage'),
('client_loaned', 'Client/Loaned'),
('rented_out', 'Rented Out'),
], string='Storage Location')
x_fc_listing_type = fields.Selection([
('owned', 'Owned'),
('borrowed', 'Borrowed'),
], string='Listing Type')
x_fc_asset_number = fields.Char(string='Asset Number')
x_fc_package_info = fields.Text(string='Package Information')
# ==========================================================================
# COMPUTED FIELDS
# ==========================================================================
@@ -107,3 +162,25 @@ class ProductTemplate(models.Model):
return self.default_code or ''
# ==========================================================================
# SECURITY DEPOSIT (added by fusion_rental)
# ==========================================================================
x_fc_security_deposit_type = fields.Selection(
[
('fixed', 'Fixed Amount'),
('percentage', 'Percentage of Rental Price'),
],
string='Security Deposit Type',
help='How the security deposit is calculated for this rental product.',
)
x_fc_security_deposit_amount = fields.Float(
string='Security Deposit Amount',
digits='Product Price',
help='Fixed dollar amount for the security deposit.',
)
x_fc_security_deposit_percent = fields.Float(
string='Security Deposit (%)',
help='Percentage of the rental line price to charge as deposit.',
)

View File

@@ -317,16 +317,6 @@ class ResConfigSettings(models.TransientModel):
help='The user who signs Page 12 on behalf of the company',
)
# =========================================================================
# GOOGLE MAPS API SETTINGS
# =========================================================================
fc_google_maps_api_key = fields.Char(
string='Google Maps API Key',
config_parameter='fusion_claims.google_maps_api_key',
help='API key for Google Maps Places autocomplete in address fields',
)
# ------------------------------------------------------------------
# AI CLIENT INTELLIGENCE
# ------------------------------------------------------------------
@@ -349,62 +339,6 @@ class ResConfigSettings(models.TransientModel):
help='Automatically parse ADP XML files when uploaded and create/update client profiles',
)
# ------------------------------------------------------------------
# TECHNICIAN MANAGEMENT
# ------------------------------------------------------------------
fc_store_open_hour = fields.Float(
string='Store Open Time',
config_parameter='fusion_claims.store_open_hour',
help='Store opening time for technician scheduling (e.g. 9.0 = 9:00 AM)',
)
fc_store_close_hour = fields.Float(
string='Store Close Time',
config_parameter='fusion_claims.store_close_hour',
help='Store closing time for technician scheduling (e.g. 18.0 = 6:00 PM)',
)
fc_google_distance_matrix_enabled = fields.Boolean(
string='Enable Distance Matrix',
config_parameter='fusion_claims.google_distance_matrix_enabled',
help='Enable Google Distance Matrix API for travel time calculations between technician tasks',
)
fc_technician_start_address = fields.Char(
string='Technician Start Address',
config_parameter='fusion_claims.technician_start_address',
help='Default start location for technician travel calculations (e.g. warehouse/office address)',
)
fc_location_retention_days = fields.Char(
string='Location History Retention (Days)',
config_parameter='fusion_claims.location_retention_days',
help='How many days to keep technician location history. '
'Leave empty = 30 days (1 month). '
'0 = delete at end of each day. '
'1+ = keep for that many days.',
)
# ------------------------------------------------------------------
# WEB PUSH NOTIFICATIONS
# ------------------------------------------------------------------
fc_push_enabled = fields.Boolean(
string='Enable Push Notifications',
config_parameter='fusion_claims.push_enabled',
help='Enable web push notifications for technician tasks',
)
fc_vapid_public_key = fields.Char(
string='VAPID Public Key',
config_parameter='fusion_claims.vapid_public_key',
help='Public key for Web Push VAPID authentication (auto-generated)',
)
fc_vapid_private_key = fields.Char(
string='VAPID Private Key',
config_parameter='fusion_claims.vapid_private_key',
help='Private key for Web Push VAPID authentication (auto-generated)',
)
fc_push_advance_minutes = fields.Integer(
string='Notification Advance (min)',
config_parameter='fusion_claims.push_advance_minutes',
help='Send push notifications this many minutes before a scheduled task',
)
# ------------------------------------------------------------------
# TWILIO SMS SETTINGS
# ------------------------------------------------------------------
@@ -477,16 +411,6 @@ class ResConfigSettings(models.TransientModel):
help='Default ODSP office contact for new ODSP cases',
)
# =========================================================================
# PORTAL FORMS
# =========================================================================
fc_ltc_form_password = fields.Char(
string='LTC Form Access Password',
config_parameter='fusion_claims.ltc_form_password',
help='Minimum 4 characters. Share with facility staff to access the repair form.',
)
# =========================================================================
# PORTAL BRANDING
# =========================================================================
@@ -609,15 +533,11 @@ class ResConfigSettings(models.TransientModel):
# an existing non-empty value (e.g. API keys, user-customized settings).
_protected_keys = [
'fusion_claims.ai_api_key',
'fusion_claims.google_maps_api_key',
'fusion_claims.vendor_code',
'fusion_claims.ai_model',
'fusion_claims.adp_posting_base_date',
'fusion_claims.application_reminder_days',
'fusion_claims.application_reminder_2_days',
'fusion_claims.store_open_hour',
'fusion_claims.store_close_hour',
'fusion_claims.technician_start_address',
]
# Snapshot existing values BEFORE super().set_values() runs
_existing = {}
@@ -656,13 +576,6 @@ class ResConfigSettings(models.TransientModel):
# Office notification recipients are stored via related field on res.company
# No need to store in ir.config_parameter
# Validate LTC form password length
form_pw = self.fc_ltc_form_password or ''
if form_pw and len(form_pw.strip()) < 4:
raise ValidationError(
'LTC Form Access Password must be at least 4 characters.'
)
# Store designated vendor signer (Many2one - manual handling)
if self.fc_designated_vendor_signer:
ICP.set_param('fusion_claims.designated_vendor_signer',

Some files were not shown because too many files have changed in this diff Show More