diff --git a/_archived_fusion_faxes/__init__.py b/_archived_fusion_faxes/__init__.py new file mode 100644 index 0000000..ea11a4a --- /dev/null +++ b/_archived_fusion_faxes/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from . import models +from . import wizard diff --git a/_archived_fusion_faxes/__manifest__.py b/_archived_fusion_faxes/__manifest__.py new file mode 100644 index 0000000..ac420a8 --- /dev/null +++ b/_archived_fusion_faxes/__manifest__.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +{ + 'name': 'Fusion Faxes', + 'version': '19.0.2.0.0', + 'category': 'Productivity', + 'summary': 'Send and receive faxes via RingCentral API from Sale Orders, Invoices, and Contacts.', + 'description': """ + Fusion Faxes + ============ + + Send faxes directly from Odoo using the RingCentral REST API. + + Features: + --------- + * Send faxes from Sale Orders and Invoices + * Fax history tracked per contact + * Fax log with status tracking (draft/sending/sent/failed) + * Cover page text support + * Multiple document attachments per fax + * Chatter integration for fax events + * RingCentral JWT authentication (server-to-server) + + Copyright 2026 Nexa Systems Inc. All rights reserved. + """, + 'author': 'Nexa Systems Inc.', + 'website': 'https://www.nexasystems.ca', + 'license': 'OPL-1', + 'depends': [ + 'base', + 'mail', + 'sale', + 'sale_management', + 'account', + ], + 'external_dependencies': { + 'python': ['ringcentral'], + }, + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'data/ir_config_parameter_data.xml', + 'data/ir_sequence_data.xml', + 'data/ir_cron_data.xml', + 'views/fusion_fax_views.xml', + 'views/dashboard_views.xml', + 'views/res_config_settings_views.xml', + 'views/res_partner_views.xml', + 'views/sale_order_views.xml', + 'views/account_move_views.xml', + 'wizard/send_fax_wizard_views.xml', + ], + 'installable': True, + 'auto_install': False, + 'application': True, +} diff --git a/_archived_fusion_faxes/data/ir_config_parameter_data.xml b/_archived_fusion_faxes/data/ir_config_parameter_data.xml new file mode 100644 index 0000000..52a2e8e --- /dev/null +++ b/_archived_fusion_faxes/data/ir_config_parameter_data.xml @@ -0,0 +1,36 @@ + + + + + + fusion_faxes.ringcentral_enabled + False + + + + fusion_faxes.ringcentral_server_url + https://platform.ringcentral.com + + + + fusion_faxes.ringcentral_client_id + + + + + fusion_faxes.ringcentral_client_secret + + + + + fusion_faxes.ringcentral_jwt_token + + + + + fusion_faxes.last_inbound_poll + + + + + diff --git a/_archived_fusion_faxes/data/ir_cron_data.xml b/_archived_fusion_faxes/data/ir_cron_data.xml new file mode 100644 index 0000000..14a10fc --- /dev/null +++ b/_archived_fusion_faxes/data/ir_cron_data.xml @@ -0,0 +1,16 @@ + + + + + + Fusion Faxes: Fetch Incoming Faxes + + code + model._cron_fetch_incoming_faxes() + 5 + minutes + True + + + + diff --git a/_archived_fusion_faxes/data/ir_sequence_data.xml b/_archived_fusion_faxes/data/ir_sequence_data.xml new file mode 100644 index 0000000..faa1612 --- /dev/null +++ b/_archived_fusion_faxes/data/ir_sequence_data.xml @@ -0,0 +1,14 @@ + + + + + + Fusion Fax + fusion.fax + FAX/ + 4 + + + + + diff --git a/_archived_fusion_faxes/models/__init__.py b/_archived_fusion_faxes/models/__init__.py new file mode 100644 index 0000000..7927abb --- /dev/null +++ b/_archived_fusion_faxes/models/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from . import fusion_fax +from . import fusion_fax_document +from . import dashboard +from . import res_config_settings +from . import res_partner +from . import sale_order +from . import account_move diff --git a/_archived_fusion_faxes/models/account_move.py b/_archived_fusion_faxes/models/account_move.py new file mode 100644 index 0000000..7b4aad9 --- /dev/null +++ b/_archived_fusion_faxes/models/account_move.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import api, fields, models + + +class AccountMove(models.Model): + _inherit = 'account.move' + + x_ff_fax_ids = fields.One2many( + 'fusion.fax', + 'account_move_id', + string='Faxes', + ) + x_ff_fax_count = fields.Integer( + string='Fax Count', + compute='_compute_fax_count', + ) + + @api.depends('x_ff_fax_ids') + def _compute_fax_count(self): + for move in self: + move.x_ff_fax_count = len(move.x_ff_fax_ids) + + def action_send_fax(self): + """Open the Send Fax wizard pre-filled with this invoice.""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Send Fax', + 'res_model': 'fusion_faxes.send.fax.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'active_model': 'account.move', + 'active_id': self.id, + }, + } + + def action_view_faxes(self): + """Open fax history for this invoice.""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Faxes', + 'res_model': 'fusion.fax', + 'view_mode': 'list,form', + 'domain': [('account_move_id', '=', self.id)], + 'context': {'default_account_move_id': self.id}, + } diff --git a/_archived_fusion_faxes/models/dashboard.py b/_archived_fusion_faxes/models/dashboard.py new file mode 100644 index 0000000..cf6c4a3 --- /dev/null +++ b/_archived_fusion_faxes/models/dashboard.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import api, fields, models, _ +import logging + +_logger = logging.getLogger(__name__) + +STATUS_DOMAINS = { + 'all': [], + 'sent': [('state', '=', 'sent')], + 'failed': [('state', '=', 'failed')], + 'draft': [('state', '=', 'draft')], + 'received': [('state', '=', 'received')], +} + + +class FusionFaxDashboard(models.TransientModel): + _name = 'fusion.fax.dashboard' + _description = 'Fusion Fax Dashboard' + _rec_name = 'name' + + name = fields.Char(default='Fax Dashboard', readonly=True) + + # KPI stat fields + total_count = fields.Integer(compute='_compute_stats') + sent_count = fields.Integer(compute='_compute_stats') + received_count = fields.Integer(compute='_compute_stats') + failed_count = fields.Integer(compute='_compute_stats') + draft_count = fields.Integer(compute='_compute_stats') + + # Recent faxes as a proper relational field for embedded list + recent_fax_ids = fields.Many2many( + 'fusion.fax', + compute='_compute_recent_faxes', + ) + + def _compute_stats(self): + Fax = self.env['fusion.fax'].sudo() + for rec in self: + rec.total_count = Fax.search_count([]) + rec.sent_count = Fax.search_count(STATUS_DOMAINS['sent']) + rec.received_count = Fax.search_count(STATUS_DOMAINS['received']) + rec.failed_count = Fax.search_count(STATUS_DOMAINS['failed']) + rec.draft_count = Fax.search_count(STATUS_DOMAINS['draft']) + + def _compute_recent_faxes(self): + Fax = self.env['fusion.fax'].sudo() + for rec in self: + rec.recent_fax_ids = Fax.search([], order='create_date desc', limit=20) + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + def action_open_all(self): + return self._open_fax_list('All Faxes', STATUS_DOMAINS['all']) + + def action_open_sent(self): + return self._open_fax_list('Sent Faxes', STATUS_DOMAINS['sent']) + + def action_open_received(self): + return self._open_fax_list('Received Faxes', STATUS_DOMAINS['received']) + + def action_open_failed(self): + return self._open_fax_list('Failed Faxes', STATUS_DOMAINS['failed']) + + def action_open_draft(self): + return self._open_fax_list('Draft Faxes', STATUS_DOMAINS['draft']) + + def _open_fax_list(self, name, domain): + return { + 'type': 'ir.actions.act_window', + 'name': name, + 'res_model': 'fusion.fax', + 'view_mode': 'list,form', + 'domain': domain, + } + + def action_send_fax(self): + return { + 'type': 'ir.actions.act_window', + 'name': 'Send Fax', + 'res_model': 'fusion_faxes.send.fax.wizard', + 'view_mode': 'form', + 'target': 'new', + } diff --git a/_archived_fusion_faxes/models/fusion_fax.py b/_archived_fusion_faxes/models/fusion_fax.py new file mode 100644 index 0000000..c8f567b --- /dev/null +++ b/_archived_fusion_faxes/models/fusion_fax.py @@ -0,0 +1,581 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +import base64 +import json +import logging +from datetime import datetime, timedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from markupsafe import Markup + +_logger = logging.getLogger(__name__) + + +class FusionFax(models.Model): + _name = 'fusion.fax' + _description = 'Fax Record' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'create_date desc' + _rec_name = 'name' + + name = fields.Char( + string='Reference', + required=True, + copy=False, + readonly=True, + default=lambda self: _('New'), + ) + direction = fields.Selection([ + ('outbound', 'Outbound'), + ('inbound', 'Inbound'), + ], string='Direction', default='outbound', required=True, tracking=True) + + partner_id = fields.Many2one( + 'res.partner', + string='Recipient', + tracking=True, + ) + fax_number = fields.Char( + string='Fax Number', + required=True, + tracking=True, + ) + state = fields.Selection([ + ('draft', 'Draft'), + ('sending', 'Sending'), + ('sent', 'Sent'), + ('failed', 'Failed'), + ('received', 'Received'), + ], string='Status', default='draft', required=True, tracking=True) + + # Inbound fax fields + sender_number = fields.Char( + string='Sender Number', + readonly=True, + ) + received_date = fields.Datetime( + string='Received Date', + readonly=True, + ) + rc_read_status = fields.Char( + string='Read Status', + readonly=True, + ) + + # Computed display fields for list views (work regardless of direction) + display_date = fields.Datetime( + string='Date', + compute='_compute_display_fields', + store=True, + ) + display_number = fields.Char( + string='Fax Number', + compute='_compute_display_fields', + store=True, + ) + + cover_page_text = fields.Text(string='Cover Page Text') + document_ids = fields.One2many( + 'fusion.fax.document', + 'fax_id', + string='Documents', + ) + document_count = fields.Integer( + compute='_compute_document_count', + ) + # Keep for backwards compat with existing records + attachment_ids = fields.Many2many( + 'ir.attachment', + 'fusion_fax_attachment_rel', + 'fax_id', + 'attachment_id', + string='Attachments (Legacy)', + ) + ringcentral_message_id = fields.Char( + string='RingCentral Message ID', + readonly=True, + copy=False, + ) + sent_date = fields.Datetime( + string='Sent Date', + readonly=True, + copy=False, + ) + sent_by_id = fields.Many2one( + 'res.users', + string='Sent By', + readonly=True, + default=lambda self: self.env.user, + ) + page_count = fields.Integer( + string='Pages', + readonly=True, + copy=False, + ) + error_message = fields.Text( + string='Error Message', + readonly=True, + copy=False, + ) + + # Links to source documents + sale_order_id = fields.Many2one( + 'sale.order', + string='Sale Order', + ondelete='set null', + tracking=True, + ) + account_move_id = fields.Many2one( + 'account.move', + string='Invoice', + ondelete='set null', + tracking=True, + ) + + def write(self, vals): + """Post chatter message when a sale order or invoice is linked.""" + old_so_ids = {rec.id: rec.sale_order_id.id for rec in self} + result = super().write(vals) + + if 'sale_order_id' in vals: + for rec in self: + new_so = rec.sale_order_id + old_so_id = old_so_ids.get(rec.id) + if new_so and new_so.id != old_so_id: + rec._post_link_chatter_message(new_so) + # Also set the partner from the SO if not already matched + if not rec.partner_id and new_so.partner_id: + rec.partner_id = new_so.partner_id + + return result + + def _post_link_chatter_message(self, sale_order): + """Post a message on the sale order when a fax is linked to it.""" + self.ensure_one() + direction_label = 'Received' if self.direction == 'inbound' else 'Sent' + date_str = '' + if self.direction == 'inbound' and self.received_date: + date_str = self.received_date.strftime('%b %d, %Y %H:%M') + elif self.sent_date: + date_str = self.sent_date.strftime('%b %d, %Y %H:%M') + + number = self.sender_number or self.fax_number or '' + body = Markup( + '

Fax Linked

' + '

%s fax %s has been linked to this order.

' + '' + ) % (direction_label, self.id, self.name, number, + date_str or '-', self.page_count or '-') + + # Attach the fax documents to the chatter message + attachment_ids = self.document_ids.mapped('attachment_id').ids + sale_order.message_post( + body=body, + message_type='notification', + attachment_ids=attachment_ids, + ) + + @api.depends('document_ids') + def _compute_document_count(self): + for rec in self: + rec.document_count = len(rec.document_ids) + + @api.depends('direction', 'sent_date', 'received_date', 'fax_number', 'sender_number') + def _compute_display_fields(self): + for rec in self: + if rec.direction == 'inbound': + rec.display_date = rec.received_date + rec.display_number = rec.sender_number or rec.fax_number + else: + rec.display_date = rec.sent_date + rec.display_number = rec.fax_number + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', _('New')) == _('New'): + vals['name'] = self.env['ir.sequence'].next_by_code('fusion.fax') or _('New') + return super().create(vals_list) + + # ------------------------------------------------------------------ + # RingCentral SDK helpers + # ------------------------------------------------------------------ + + def _get_rc_sdk(self): + """Initialize and authenticate the RingCentral SDK. Returns (sdk, platform) tuple.""" + ICP = self.env['ir.config_parameter'].sudo() + enabled = ICP.get_param('fusion_faxes.ringcentral_enabled', 'False') + if enabled not in ('True', 'true', '1'): + raise UserError(_('RingCentral faxing is not enabled. Go to Settings > Fusion Faxes to enable it.')) + + client_id = ICP.get_param('fusion_faxes.ringcentral_client_id', '') + client_secret = ICP.get_param('fusion_faxes.ringcentral_client_secret', '') + server_url = ICP.get_param('fusion_faxes.ringcentral_server_url', 'https://platform.ringcentral.com') + jwt_token = ICP.get_param('fusion_faxes.ringcentral_jwt_token', '') + + if not all([client_id, client_secret, jwt_token]): + raise UserError(_( + 'RingCentral credentials are not configured. ' + 'Go to Settings > Fusion Faxes and enter Client ID, Client Secret, and JWT Token.' + )) + + try: + from ringcentral import SDK + except ImportError: + raise UserError(_( + 'The ringcentral Python package is not installed. ' + 'Run: pip install ringcentral' + )) + + sdk = SDK(client_id, client_secret, server_url) + platform = sdk.platform() + platform.login(jwt=jwt_token) + return sdk, platform + + def _get_ordered_attachments(self): + """Return attachments in the correct order: document_ids by sequence, or legacy attachment_ids.""" + self.ensure_one() + if self.document_ids: + return self.document_ids.sorted('sequence').mapped('attachment_id') + return self.attachment_ids + + def _send_fax(self): + """Send this fax record via RingCentral API.""" + self.ensure_one() + + attachments = self._get_ordered_attachments() + if not attachments: + raise UserError(_('Please attach at least one document to send.')) + + self.write({'state': 'sending', 'error_message': False}) + + try: + sdk, platform = self._get_rc_sdk() + + # Use the SDK's multipart builder + builder = sdk.create_multipart_builder() + + # Set the JSON body (metadata) + body = { + 'to': [{'phoneNumber': self.fax_number}], + 'faxResolution': 'High', + } + if self.cover_page_text: + body['coverPageText'] = self.cover_page_text + + builder.set_body(body) + + # Add document attachments in sequence order + for attachment in attachments: + file_content = base64.b64decode(attachment.datas) + builder.add((attachment.name, file_content)) + + # Build the request and send + request = builder.request('/restapi/v1.0/account/~/extension/~/fax') + response = platform.send_request(request) + result = response.json() + + # Extract response fields + message_id = '' + page_count = 0 + if hasattr(result, 'id'): + message_id = str(result.id) + elif isinstance(result, dict): + message_id = str(result.get('id', '')) + + if hasattr(result, 'pageCount'): + page_count = result.pageCount + elif isinstance(result, dict): + page_count = result.get('pageCount', 0) + + self.write({ + 'state': 'sent', + 'ringcentral_message_id': message_id, + 'sent_date': fields.Datetime.now(), + 'sent_by_id': self.env.user.id, + 'page_count': page_count, + }) + + # Post chatter message on linked documents + self._post_fax_chatter_message(success=True) + + _logger.info("Fax %s sent successfully. RC Message ID: %s", self.name, message_id) + + except UserError: + raise + except Exception as e: + error_msg = str(e) + self.write({ + 'state': 'failed', + 'error_message': error_msg, + }) + self._post_fax_chatter_message(success=False) + _logger.exception("Fax %s failed to send", self.name) + raise UserError(_('Fax sending failed: %s') % error_msg) + + def _post_fax_chatter_message(self, success=True): + """Post a chatter message on the linked sale order or invoice.""" + self.ensure_one() + if success: + body = Markup( + '

Fax Sent

' + '

Fax %s sent successfully to %s (%s).

' + '

Pages: %s | RingCentral ID: %s

' + ) % (self.name, self.partner_id.name, self.fax_number, + self.page_count or '-', self.ringcentral_message_id or '-') + else: + body = Markup( + '

Fax Failed

' + '

Fax %s to %s (%s) failed.

' + '

Error: %s

' + ) % (self.name, self.partner_id.name, self.fax_number, + self.error_message or 'Unknown error') + + if self.sale_order_id: + self.sale_order_id.message_post(body=body, message_type='notification') + if self.account_move_id: + self.account_move_id.message_post(body=body, message_type='notification') + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + def action_send(self): + """Button action to send this fax.""" + self.ensure_one() + self._send_fax() + + def action_retry(self): + """Retry a failed fax.""" + self.ensure_one() + if self.state != 'failed': + raise UserError(_('Only failed faxes can be retried.')) + self._send_fax() + + def action_resend(self): + """Resend a previously sent fax with all the same attachments.""" + self.ensure_one() + if self.state != 'sent': + raise UserError(_('Only sent faxes can be resent.')) + self._send_fax() + + def action_open_sale_order(self): + """Open the linked sale order.""" + self.ensure_one() + if not self.sale_order_id: + return + return { + 'type': 'ir.actions.act_window', + 'name': self.sale_order_id.name, + 'res_model': 'sale.order', + 'res_id': self.sale_order_id.id, + 'view_mode': 'form', + } + + def action_reset_to_draft(self): + """Reset a failed fax back to draft.""" + self.ensure_one() + if self.state not in ('failed',): + raise UserError(_('Only failed faxes can be reset to draft.')) + self.write({ + 'state': 'draft', + 'error_message': False, + }) + + # ------------------------------------------------------------------ + # Incoming fax polling + # ------------------------------------------------------------------ + + @api.model + def _cron_fetch_incoming_faxes(self): + """Poll RingCentral for inbound faxes and create records.""" + ICP = self.env['ir.config_parameter'].sudo() + enabled = ICP.get_param('fusion_faxes.ringcentral_enabled', 'False') + if enabled not in ('True', 'true', '1'): + return + + client_id = ICP.get_param('fusion_faxes.ringcentral_client_id', '') + client_secret = ICP.get_param('fusion_faxes.ringcentral_client_secret', '') + server_url = ICP.get_param('fusion_faxes.ringcentral_server_url', 'https://platform.ringcentral.com') + jwt_token = ICP.get_param('fusion_faxes.ringcentral_jwt_token', '') + + if not all([client_id, client_secret, jwt_token]): + _logger.warning("Fusion Faxes: RingCentral credentials not configured, skipping inbound poll.") + return + + try: + from ringcentral import SDK + except ImportError: + _logger.error("Fusion Faxes: ringcentral package not installed.") + return + + # Determine dateFrom: last poll or 1 year ago for first run + last_poll = ICP.get_param('fusion_faxes.last_inbound_poll', '') + if last_poll: + date_from = last_poll + else: + one_year_ago = datetime.utcnow() - timedelta(days=365) + date_from = one_year_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z') + + try: + sdk = SDK(client_id, client_secret, server_url) + platform = sdk.platform() + platform.login(jwt=jwt_token) + + total_imported = 0 + total_skipped = 0 + + # Fetch first page + endpoint = ( + '/restapi/v1.0/account/~/extension/~/message-store' + f'?messageType=Fax&direction=Inbound&dateFrom={date_from}' + '&perPage=100' + ) + + while endpoint: + response = platform.get(endpoint) + data = response.json() + + records = [] + if hasattr(data, 'records'): + records = data.records + elif isinstance(data, dict): + records = data.get('records', []) + + for msg in records: + msg_id = str(msg.get('id', '')) if isinstance(msg, dict) else str(getattr(msg, 'id', '')) + + # Deduplicate + existing = self.search_count([('ringcentral_message_id', '=', msg_id)]) + if existing: + total_skipped += 1 + continue + + imported = self._import_inbound_fax(msg, platform) + if imported: + total_imported += 1 + + # Handle pagination + endpoint = None + navigation = None + if isinstance(data, dict): + navigation = data.get('navigation', {}) + elif hasattr(data, 'navigation'): + navigation = data.navigation + + if navigation: + next_page = None + if isinstance(navigation, dict): + next_page = navigation.get('nextPage', {}) + elif hasattr(navigation, 'nextPage'): + next_page = navigation.nextPage + + if next_page: + next_uri = next_page.get('uri', '') if isinstance(next_page, dict) else getattr(next_page, 'uri', '') + if next_uri: + endpoint = next_uri + + # Update last poll timestamp + ICP.set_param('fusion_faxes.last_inbound_poll', datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')) + + if total_imported: + _logger.info("Fusion Faxes: Imported %d inbound faxes, skipped %d duplicates.", total_imported, total_skipped) + + except Exception: + _logger.exception("Fusion Faxes: Error fetching inbound faxes from RingCentral.") + + def _import_inbound_fax(self, msg, platform): + """Import a single inbound fax message from RingCentral.""" + try: + # Extract fields (handle both dict and SDK JsonObject responses) + if isinstance(msg, dict): + msg_id = str(msg.get('id', '')) + from_info = msg.get('from', {}) + sender = from_info.get('phoneNumber', '') if isinstance(from_info, dict) else '' + creation_time = msg.get('creationTime', '') + read_status = msg.get('readStatus', '') + page_count = msg.get('faxPageCount', 0) + attachments = msg.get('attachments', []) + else: + msg_id = str(getattr(msg, 'id', '')) + # SDK exposes 'from' as 'from_' since 'from' is a Python keyword + from_info = getattr(msg, 'from_', None) or getattr(msg, 'from', None) + sender = getattr(from_info, 'phoneNumber', '') if from_info else '' + creation_time = getattr(msg, 'creationTime', '') + read_status = getattr(msg, 'readStatus', '') + page_count = getattr(msg, 'faxPageCount', 0) + attachments = getattr(msg, 'attachments', []) + + # Parse received datetime + received_dt = False + if creation_time: + try: + clean_time = creation_time.replace('Z', '+00:00') + received_dt = datetime.fromisoformat(clean_time).strftime('%Y-%m-%d %H:%M:%S') + except (ValueError, AttributeError): + received_dt = False + + # Try to match sender to a partner + partner = False + if sender: + partner = self.env['res.partner'].sudo().search( + [('x_ff_fax_number', '=', sender)], limit=1 + ) + + # Download the PDF attachment + document_lines = [] + for att in attachments: + att_uri = att.get('uri', '') if isinstance(att, dict) else getattr(att, 'uri', '') + att_type = att.get('contentType', '') if isinstance(att, dict) else getattr(att, 'contentType', '') + + if not att_uri: + continue + + try: + att_response = platform.get(att_uri) + pdf_content = att_response.body() + if not pdf_content: + continue + + file_ext = 'pdf' if 'pdf' in (att_type or '') else 'bin' + file_name = f'FAX_IN_{msg_id}.{file_ext}' + + ir_attachment = self.env['ir.attachment'].sudo().create({ + 'name': file_name, + 'type': 'binary', + 'datas': base64.b64encode(pdf_content), + 'mimetype': att_type or 'application/pdf', + 'res_model': 'fusion.fax', + }) + document_lines.append((0, 0, { + 'sequence': 10, + 'attachment_id': ir_attachment.id, + })) + except Exception: + _logger.exception("Fusion Faxes: Failed to download attachment for message %s", msg_id) + + # Create the fax record + self.sudo().create({ + 'direction': 'inbound', + 'state': 'received', + 'fax_number': sender or 'Unknown', + 'sender_number': sender, + 'partner_id': partner.id if partner else False, + 'ringcentral_message_id': msg_id, + 'received_date': received_dt, + 'page_count': page_count or 0, + 'rc_read_status': read_status, + 'document_ids': document_lines, + }) + return True + + except Exception: + _logger.exception("Fusion Faxes: Failed to import inbound fax message.") + return False diff --git a/_archived_fusion_faxes/models/fusion_fax_document.py b/_archived_fusion_faxes/models/fusion_fax_document.py new file mode 100644 index 0000000..81d73a3 --- /dev/null +++ b/_archived_fusion_faxes/models/fusion_fax_document.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import api, fields, models + + +class FusionFaxDocument(models.Model): + _name = 'fusion.fax.document' + _description = 'Fax Document Line' + _order = 'sequence, id' + + fax_id = fields.Many2one( + 'fusion.fax', + string='Fax', + required=True, + ondelete='cascade', + ) + sequence = fields.Integer( + string='Order', + default=10, + ) + attachment_id = fields.Many2one( + 'ir.attachment', + string='Document', + required=True, + ondelete='cascade', + ) + file_name = fields.Char( + related='attachment_id.name', + string='File Name', + ) + mimetype = fields.Char( + related='attachment_id.mimetype', + string='Type', + ) + + def action_preview(self): + """Open the attachment in Odoo's built-in PDF viewer dialog.""" + self.ensure_one() + return { + 'type': 'ir.actions.client', + 'tag': 'fusion_claims.preview_document', + 'params': { + 'attachment_id': self.attachment_id.id, + 'title': self.file_name or 'Document Preview', + }, + } diff --git a/_archived_fusion_faxes/models/res_config_settings.py b/_archived_fusion_faxes/models/res_config_settings.py new file mode 100644 index 0000000..a0a2755 --- /dev/null +++ b/_archived_fusion_faxes/models/res_config_settings.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import api, fields, models +import logging + +_logger = logging.getLogger(__name__) + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + ff_ringcentral_enabled = fields.Boolean( + string='Enable RingCentral Faxing', + config_parameter='fusion_faxes.ringcentral_enabled', + ) + ff_ringcentral_server_url = fields.Char( + string='RingCentral Server URL', + config_parameter='fusion_faxes.ringcentral_server_url', + default='https://platform.ringcentral.com', + ) + ff_ringcentral_client_id = fields.Char( + string='Client ID', + config_parameter='fusion_faxes.ringcentral_client_id', + groups='fusion_faxes.group_fax_manager', + ) + ff_ringcentral_client_secret = fields.Char( + string='Client Secret', + config_parameter='fusion_faxes.ringcentral_client_secret', + groups='fusion_faxes.group_fax_manager', + ) + ff_ringcentral_jwt_token = fields.Char( + string='JWT Token', + config_parameter='fusion_faxes.ringcentral_jwt_token', + groups='fusion_faxes.group_fax_manager', + ) + + def action_test_ringcentral_connection(self): + """Test connection to RingCentral using stored credentials.""" + self.ensure_one() + ICP = self.env['ir.config_parameter'].sudo() + client_id = ICP.get_param('fusion_faxes.ringcentral_client_id', '') + client_secret = ICP.get_param('fusion_faxes.ringcentral_client_secret', '') + server_url = ICP.get_param('fusion_faxes.ringcentral_server_url', 'https://platform.ringcentral.com') + jwt_token = ICP.get_param('fusion_faxes.ringcentral_jwt_token', '') + + if not all([client_id, client_secret, jwt_token]): + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'Configuration Missing', + 'message': 'Please fill in Client ID, Client Secret, and JWT Token before testing.', + 'type': 'warning', + 'sticky': False, + }, + } + + try: + from ringcentral import SDK + sdk = SDK(client_id, client_secret, server_url) + platform = sdk.platform() + platform.login(jwt=jwt_token) + + # Fetch account info to verify + res = platform.get('/restapi/v1.0/account/~/extension/~') + ext_info = res.json() + ext_name = ext_info.name if hasattr(ext_info, 'name') else str(ext_info.get('name', 'Unknown')) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'Connection Successful', + 'message': f'Connected to RingCentral as: {ext_name}', + 'type': 'success', + 'sticky': False, + }, + } + except ImportError: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'SDK Not Installed', + 'message': 'The ringcentral Python package is not installed. Run: pip install ringcentral', + 'type': 'danger', + 'sticky': True, + }, + } + except Exception as e: + _logger.exception("RingCentral connection test failed") + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'Connection Failed', + 'message': str(e), + 'type': 'danger', + 'sticky': True, + }, + } + + def set_values(self): + """Protect credential fields from being blanked accidentally.""" + protected_keys = [ + 'fusion_faxes.ringcentral_client_id', + 'fusion_faxes.ringcentral_client_secret', + 'fusion_faxes.ringcentral_jwt_token', + ] + ICP = self.env['ir.config_parameter'].sudo() + for key in protected_keys: + field_map = { + 'fusion_faxes.ringcentral_client_id': 'ff_ringcentral_client_id', + 'fusion_faxes.ringcentral_client_secret': 'ff_ringcentral_client_secret', + 'fusion_faxes.ringcentral_jwt_token': 'ff_ringcentral_jwt_token', + } + field_name = field_map[key] + new_val = getattr(self, field_name, None) + if new_val in (None, False, ''): + existing = ICP.get_param(key, '') + if existing: + ICP.set_param(key, existing) + return super().set_values() diff --git a/_archived_fusion_faxes/models/res_partner.py b/_archived_fusion_faxes/models/res_partner.py new file mode 100644 index 0000000..8914799 --- /dev/null +++ b/_archived_fusion_faxes/models/res_partner.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import api, fields, models + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + x_ff_fax_number = fields.Char(string='Fax Number') + x_ff_fax_ids = fields.One2many( + 'fusion.fax', + 'partner_id', + string='Fax History', + ) + x_ff_fax_count = fields.Integer( + string='Fax Count', + compute='_compute_fax_count', + ) + + @api.depends('x_ff_fax_ids') + def _compute_fax_count(self): + for partner in self: + partner.x_ff_fax_count = len(partner.x_ff_fax_ids) + + def action_view_faxes(self): + """Open fax history for this contact.""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Fax History', + 'res_model': 'fusion.fax', + 'view_mode': 'list,form', + 'domain': [('partner_id', '=', self.id)], + 'context': {'default_partner_id': self.id}, + } diff --git a/_archived_fusion_faxes/models/sale_order.py b/_archived_fusion_faxes/models/sale_order.py new file mode 100644 index 0000000..485afdb --- /dev/null +++ b/_archived_fusion_faxes/models/sale_order.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import api, fields, models + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + x_ff_fax_ids = fields.One2many( + 'fusion.fax', + 'sale_order_id', + string='Faxes', + ) + x_ff_fax_count = fields.Integer( + string='Fax Count', + compute='_compute_fax_count', + ) + + @api.depends('x_ff_fax_ids') + def _compute_fax_count(self): + for order in self: + order.x_ff_fax_count = len(order.x_ff_fax_ids) + + def action_send_fax(self): + """Open the Send Fax wizard pre-filled with this sale order.""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Send Fax', + 'res_model': 'fusion_faxes.send.fax.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'active_model': 'sale.order', + 'active_id': self.id, + }, + } + + def action_view_faxes(self): + """Open fax history for this sale order.""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Faxes', + 'res_model': 'fusion.fax', + 'view_mode': 'list,form', + 'domain': [('sale_order_id', '=', self.id)], + 'context': {'default_sale_order_id': self.id}, + } diff --git a/_archived_fusion_faxes/security/ir.model.access.csv b/_archived_fusion_faxes/security/ir.model.access.csv new file mode 100644 index 0000000..f60eae9 --- /dev/null +++ b/_archived_fusion_faxes/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_fax_user,fusion.fax.user,model_fusion_fax,group_fax_user,1,1,1,0 +access_fusion_fax_manager,fusion.fax.manager,model_fusion_fax,group_fax_manager,1,1,1,1 +access_fusion_send_fax_wizard_user,fusion.send.fax.wizard.user,model_fusion_faxes_send_fax_wizard,group_fax_user,1,1,1,1 +access_fusion_send_fax_wizard_line_user,fusion.send.fax.wizard.line.user,model_fusion_faxes_send_fax_wizard_line,group_fax_user,1,1,1,1 +access_fusion_fax_document_user,fusion.fax.document.user,model_fusion_fax_document,group_fax_user,1,1,1,0 +access_fusion_fax_document_manager,fusion.fax.document.manager,model_fusion_fax_document,group_fax_manager,1,1,1,1 +access_fusion_fax_dashboard_user,fusion.fax.dashboard.user,model_fusion_fax_dashboard,group_fax_user,1,1,1,1 diff --git a/_archived_fusion_faxes/security/security.xml b/_archived_fusion_faxes/security/security.xml new file mode 100644 index 0000000..c457232 --- /dev/null +++ b/_archived_fusion_faxes/security/security.xml @@ -0,0 +1,36 @@ + + + + + + + Fusion Faxes / User + + + + + + Fusion Faxes / Manager + + + + + + + + Fax: user sees own faxes + + [('sent_by_id', '=', user.id)] + + + + + + Fax: manager sees all faxes + + [(1, '=', 1)] + + + + + diff --git a/_archived_fusion_faxes/static/description/icon.png b/_archived_fusion_faxes/static/description/icon.png new file mode 100644 index 0000000..1d12817 Binary files /dev/null and b/_archived_fusion_faxes/static/description/icon.png differ diff --git a/_archived_fusion_faxes/views/account_move_views.xml b/_archived_fusion_faxes/views/account_move_views.xml new file mode 100644 index 0000000..85ce70d --- /dev/null +++ b/_archived_fusion_faxes/views/account_move_views.xml @@ -0,0 +1,31 @@ + + + + + + account.move.form.inherit.fusion_faxes + account.move + + + + + + + + + + + + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + +
+
+ + + + Dashboard + fusion.fax.dashboard + form + + current + + + + + +
diff --git a/_archived_fusion_faxes/views/fusion_fax_views.xml b/_archived_fusion_faxes/views/fusion_fax_views.xml new file mode 100644 index 0000000..e3ec033 --- /dev/null +++ b/_archived_fusion_faxes/views/fusion_fax_views.xml @@ -0,0 +1,259 @@ + + + + + + fusion.fax.form + fusion.fax + +
+
+ +
+ +
+ +
+
+

+ +

+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
diff --git a/_archived_fusion_faxes/views/sale_order_views.xml b/_archived_fusion_faxes/views/sale_order_views.xml new file mode 100644 index 0000000..8a39ccb --- /dev/null +++ b/_archived_fusion_faxes/views/sale_order_views.xml @@ -0,0 +1,31 @@ + + + + + + sale.order.form.inherit.fusion_faxes + sale.order + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_accounts/fusion_accounts/views/fusion_accounts_log_views.xml b/fusion_accounts/fusion_accounts/views/fusion_accounts_log_views.xml new file mode 100644 index 0000000..94409a1 --- /dev/null +++ b/fusion_accounts/fusion_accounts/views/fusion_accounts_log_views.xml @@ -0,0 +1,130 @@ + + + + + + + fusion.accounts.log.list + fusion.accounts.log + + +
+
+ + + + + + + + + +
+
+
+ + + + + + fusion.accounts.log.form + fusion.accounts.log + +
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + fusion.accounts.log.search + fusion.accounts.log + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/fusion_accounts/fusion_accounts/views/fusion_accounts_menus.xml b/fusion_accounts/fusion_accounts/views/fusion_accounts_menus.xml new file mode 100644 index 0000000..e3c39c1 --- /dev/null +++ b/fusion_accounts/fusion_accounts/views/fusion_accounts_menus.xml @@ -0,0 +1,190 @@ + + + + + + + + + Bills from Email + account.move + list,form + [('move_type', '=', 'in_invoice'), ('x_fa_created_from_email', '=', True)] + {'default_move_type': 'in_invoice'} + +

+ No bills from email yet +

+

+ Bills will appear here when incoming emails create vendor bills automatically. +

+
+
+ + + + All Vendor Bills + account.move + list,form + [('move_type', '=', 'in_invoice')] + {'default_move_type': 'in_invoice'} + + + + + Blocked Vendors + res.partner + list,form + + [('x_fa_block_email_bill', '=', True)] + {'default_x_fa_block_email_bill': True} + +

+ No blocked vendors +

+

+ Vendors blocked from automatic email bill creation will appear here. + Block vendors whose bills should be created through Purchase Orders instead. +

+
+
+ + + + Vendors with Active POs + res.partner + list,form + + [('purchase_line_ids', '!=', False), ('supplier_rank', '>', 0)] + +

+ No vendors with purchase orders +

+

+ Vendors with Purchase Orders appear here. + Consider blocking these vendors from automatic email bill creation. +

+
+
+ + + + All Vendors + res.partner + list,form + + [('supplier_rank', '>', 0)] + + + + + Activity Log + fusion.accounts.log + list,form + +

+ No activity logged yet +

+

+ Email processing activity will be logged here automatically. +

+
+
+ + + + Fusion Accounts Settings + res.config.settings + form + current + {'module': 'fusion_accounts'} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/fusion_accounts/fusion_accounts/views/res_config_settings_views.xml b/fusion_accounts/fusion_accounts/views/res_config_settings_views.xml new file mode 100644 index 0000000..58327de --- /dev/null +++ b/fusion_accounts/fusion_accounts/views/res_config_settings_views.xml @@ -0,0 +1,73 @@ + + + + + + + res.config.settings.fusion.accounts + res.config.settings + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ +
+
+
+
+
diff --git a/fusion_accounts/fusion_accounts/views/res_partner_views.xml b/fusion_accounts/fusion_accounts/views/res_partner_views.xml new file mode 100644 index 0000000..f04a644 --- /dev/null +++ b/fusion_accounts/fusion_accounts/views/res_partner_views.xml @@ -0,0 +1,59 @@ + + + + + + + res.partner.form.fusion.accounts + res.partner + + 50 + + + + + + + + + + + + + + + + + + + res.partner.list.fusion.accounts + res.partner + 99 + + +
+
+ + + + + +
+
+
+ + + + +
diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/README.md b/fusion_authorizer_portal/fusion_authorizer_portal/README.md new file mode 100644 index 0000000..36a60a2 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/README.md @@ -0,0 +1,721 @@ +# Fusion Authorizer & Sales Portal + +**Version:** 19.0.1.0.0 +**License:** LGPL-3 +**Category:** Sales/Portal +**Author:** Fusion Claims + +## Table of Contents + +1. [Overview](#overview) +2. [Features](#features) +3. [Installation](#installation) +4. [Configuration](#configuration) +5. [Models](#models) +6. [Controllers & Routes](#controllers--routes) +7. [Security](#security) +8. [Frontend Assets](#frontend-assets) +9. [Email Templates](#email-templates) +10. [User Guide](#user-guide) +11. [API Reference](#api-reference) +12. [Troubleshooting](#troubleshooting) +13. [Changelog](#changelog) + +--- + +## Overview + +The **Fusion Authorizer & Sales Portal** module extends Odoo's portal functionality to provide external access for two key user types: + +- **Authorizers (Occupational Therapists/OTs):** Healthcare professionals who authorize ADP (Assistive Devices Program) claims +- **Sales Representatives:** Field sales staff who conduct client assessments and manage orders + +This module integrates with the `fusion_claims` module to provide a seamless workflow for ADP claims management, from initial client assessment through to order completion. + +### Target Platform +- **Odoo Enterprise v19** +- Requires: `base`, `sale`, `portal`, `website`, `mail`, `fusion_claims` + +--- + +## Features + +### Authorizer Portal +- View all assigned ADP cases with full details (excluding internal costs) +- Real-time search by client name, reference numbers, or claim number +- Upload ADP application documents with revision tracking +- Add comments/notes to cases +- Download submitted ADP applications +- Receive email notifications for new assignments and status changes + +### Sales Rep Portal +- View sales cases linked to the logged-in user +- Start and manage client assessments +- Record detailed wheelchair specifications and measurements +- Capture digital signatures for ADP pages 11 & 12 +- Track assessment progress through workflow states + +### Assessment System +- Comprehensive client information collection +- Wheelchair specifications (seat width, depth, height, cushion type, etc.) +- Accessibility and mobility needs documentation +- Touch-friendly digital signature capture +- Automatic draft Sale Order creation upon completion +- Document distribution to authorizers, sales reps, and internal records +- Automated email notifications + +--- + +## Installation + +### Prerequisites +1. Odoo Enterprise v19 installed and running +2. The `fusion_claims` module installed and configured +3. Portal module enabled +4. Website module enabled +5. Mail module configured with outgoing email server + +### Installation Steps + +1. **Copy the module** to your Odoo addons directory: + ```bash + cp -r fusion_authorizer_portal /path/to/odoo/custom-addons/ + ``` + +2. **Update the apps list** in Odoo: + - Go to Apps menu + - Click "Update Apps List" + - Search for "Fusion Authorizer" + +3. **Install the module**: + - Click Install on "Fusion Authorizer & Sales Portal" + - Wait for installation to complete + +4. **Restart Odoo** (recommended): + ```bash + docker restart odoo-app # For Docker installations + # OR + sudo systemctl restart odoo # For systemd installations + ``` + +--- + +## Configuration + +### Granting Portal Access to Users + +1. Navigate to **Contacts** in Odoo backend +2. Open the contact record for the authorizer or sales rep +3. Go to the **Portal Access** tab +4. Check the appropriate role: + - `Is Authorizer` - For Occupational Therapists + - `Is Sales Rep (Portal)` - For Sales Representatives +5. Click the **Grant Portal Access** button +6. An invitation email will be sent to the contact's email address + +### Setting Up Authorizers on Sales Orders + +1. Open a Sales Order +2. In the order details, set the **Authorizer** field (`x_fc_authorizer_id`) +3. The authorizer will receive an email notification about the assignment +4. The case will appear in their portal dashboard + +--- + +## Models + +### New Models + +#### `fusion.assessment` +**Wheelchair Assessment Record** + +Captures comprehensive client assessment data including: + +| Field Group | Fields | +|-------------|--------| +| **Client Info** | `client_name`, `client_first_name`, `client_last_name`, `client_street`, `client_city`, `client_state`, `client_postal_code`, `client_country_id`, `client_phone`, `client_mobile`, `client_email`, `client_dob`, `client_health_card`, `client_reference_1`, `client_reference_2` | +| **Participants** | `sales_rep_id` (res.users), `authorizer_id` (res.partner) | +| **Assessment Details** | `assessment_date`, `assessment_location`, `assessment_location_notes` | +| **Measurements** | `seat_width`, `seat_depth`, `seat_to_floor_height`, `back_height`, `armrest_height`, `footrest_length`, `overall_width`, `overall_length`, `overall_height`, `seat_angle`, `back_angle`, `client_weight`, `client_height` | +| **Product Types** | `cushion_type`, `cushion_notes`, `backrest_type`, `backrest_notes`, `frame_type`, `frame_notes`, `wheel_type`, `wheel_notes` | +| **Needs** | `mobility_notes`, `accessibility_notes`, `special_requirements`, `diagnosis` | +| **Signatures** | `signature_page_11`, `signature_page_11_name`, `signature_page_11_date`, `signature_page_12`, `signature_page_12_name`, `signature_page_12_date` | +| **Status** | `state` (draft, pending_signature, completed, cancelled) | +| **References** | `reference` (auto-generated ASM-XXXXX), `sale_order_id`, `partner_id` | + +**Key Methods:** +- `action_complete()` - Completes assessment, creates draft Sale Order, sends notifications +- `_ensure_partner()` - Creates or links res.partner for the client +- `_create_draft_sale_order()` - Generates Sale Order with specifications +- `_generate_signed_documents()` - Creates document records for signatures +- `_send_completion_notifications()` - Sends emails to authorizer and client + +--- + +#### `fusion.adp.document` +**ADP Document Management with Revision Tracking** + +| Field | Type | Description | +|-------|------|-------------| +| `sale_order_id` | Many2one | Link to Sale Order | +| `assessment_id` | Many2one | Link to Assessment | +| `document_type` | Selection | full_application, page_11, page_12, pages_11_12, final_submission, other | +| `file` | Binary | Document file content | +| `filename` | Char | Original filename | +| `file_size` | Integer | File size in bytes | +| `mimetype` | Char | MIME type | +| `revision` | Integer | Revision number (auto-incremented) | +| `revision_note` | Text | Notes about this revision | +| `is_current` | Boolean | Whether this is the current version | +| `uploaded_by` | Many2one | User who uploaded | +| `upload_date` | Datetime | Upload timestamp | +| `source` | Selection | portal, internal, assessment | + +**Key Methods:** +- `action_download()` - Download the document +- `get_documents_for_order()` - Get all documents for a sale order +- `get_revision_history()` - Get all revisions of a document type + +--- + +#### `fusion.authorizer.comment` +**Portal Comments System** + +| Field | Type | Description | +|-------|------|-------------| +| `sale_order_id` | Many2one | Link to Sale Order | +| `assessment_id` | Many2one | Link to Assessment | +| `author_id` | Many2one | res.partner who authored | +| `author_user_id` | Many2one | res.users who authored | +| `comment` | Text | Comment content | +| `comment_type` | Selection | general, question, update, approval | +| `is_internal` | Boolean | Internal-only comment | + +--- + +### Extended Models + +#### `res.partner` (Extended) + +| New Field | Type | Description | +|-----------|------|-------------| +| `is_authorizer` | Boolean | Partner is an Authorizer/OT | +| `is_sales_rep_portal` | Boolean | Partner is a Sales Rep with portal access | +| `authorizer_portal_user_id` | Many2one | Linked portal user account | +| `assigned_case_count` | Integer | Computed count of assigned cases | +| `assessment_count` | Integer | Computed count of assessments | + +**New Methods:** +- `action_grant_portal_access()` - Creates portal user and sends invitation +- `action_view_assigned_cases()` - Opens list of assigned Sale Orders +- `action_view_assessments()` - Opens list of assessments + +--- + +#### `sale.order` (Extended) + +| New Field | Type | Description | +|-----------|------|-------------| +| `portal_comment_ids` | One2many | Comments from portal users | +| `portal_comment_count` | Integer | Computed comment count | +| `portal_document_ids` | One2many | Documents uploaded via portal | +| `portal_document_count` | Integer | Computed document count | +| `assessment_id` | Many2one | Source assessment that created this order | +| `portal_authorizer_id` | Many2one | Authorizer reference (computed from x_fc_authorizer_id) | + +**New Methods:** +- `_send_authorizer_assignment_notification()` - Email on authorizer assignment +- `_send_status_change_notification()` - Email on status change +- `get_portal_display_data()` - Safe data for portal display (excludes costs) +- `get_authorizer_portal_cases()` - Search cases for authorizer portal +- `get_sales_rep_portal_cases()` - Search cases for sales rep portal + +--- + +## Controllers & Routes + +### Authorizer Portal Routes + +| Route | Method | Auth | Description | +|-------|--------|------|-------------| +| `/my/authorizer` | GET | user | Authorizer dashboard | +| `/my/authorizer/cases` | GET | user | List of assigned cases | +| `/my/authorizer/cases/search` | POST | user | AJAX search (jsonrpc) | +| `/my/authorizer/case/` | GET | user | Case detail view | +| `/my/authorizer/case//comment` | POST | user | Add comment to case | +| `/my/authorizer/case//upload` | POST | user | Upload document | +| `/my/authorizer/document//download` | GET | user | Download document | + +### Sales Rep Portal Routes + +| Route | Method | Auth | Description | +|-------|--------|------|-------------| +| `/my/sales` | GET | user | Sales rep dashboard | +| `/my/sales/cases` | GET | user | List of sales cases | +| `/my/sales/cases/search` | POST | user | AJAX search (jsonrpc) | +| `/my/sales/case/` | GET | user | Case detail view | + +### Assessment Routes + +| Route | Method | Auth | Description | +|-------|--------|------|-------------| +| `/my/assessments` | GET | user | List of assessments | +| `/my/assessment/new` | GET | user | New assessment form | +| `/my/assessment/` | GET | user | View/edit assessment | +| `/my/assessment/save` | POST | user | Save assessment data | +| `/my/assessment//signatures` | GET | user | Signature capture page | +| `/my/assessment//save_signature` | POST | user | Save signature (jsonrpc) | +| `/my/assessment//complete` | POST | user | Complete assessment | + +--- + +## Security + +### Security Groups + +| Group | XML ID | Description | +|-------|--------|-------------| +| Authorizer Portal | `group_authorizer_portal` | Access to authorizer portal features | +| Sales Rep Portal | `group_sales_rep_portal` | Access to sales rep portal features | + +### Record Rules + +| Model | Rule | Description | +|-------|------|-------------| +| `fusion.authorizer.comment` | Portal Read | Users can read non-internal comments on their cases | +| `fusion.authorizer.comment` | Portal Create | Users can create comments on their cases | +| `fusion.adp.document` | Portal Read | Users can read documents on their cases | +| `fusion.adp.document` | Portal Create | Users can upload documents to their cases | +| `fusion.assessment` | Portal Access | Users can access assessments they're linked to | +| `sale.order` | Portal Authorizer | Authorizers can view their assigned orders | + +### Access Rights (ir.model.access.csv) + +| Model | Group | Read | Write | Create | Unlink | +|-------|-------|------|-------|--------|--------| +| `fusion.authorizer.comment` | base.group_user | 1 | 1 | 1 | 1 | +| `fusion.authorizer.comment` | base.group_portal | 1 | 0 | 1 | 0 | +| `fusion.adp.document` | base.group_user | 1 | 1 | 1 | 1 | +| `fusion.adp.document` | base.group_portal | 1 | 0 | 1 | 0 | +| `fusion.assessment` | base.group_user | 1 | 1 | 1 | 1 | +| `fusion.assessment` | base.group_portal | 1 | 1 | 1 | 0 | + +--- + +## Frontend Assets + +### CSS (`static/src/css/portal_style.css`) + +Custom portal styling with a dark blue and green color scheme: + +- **Primary Color:** Dark blue (#1e3a5f) +- **Secondary Color:** Medium blue (#2c5282) +- **Accent Color:** Green (#38a169) +- **Background:** Light gray (#f7fafc) + +Styled components: +- Portal cards with shadow effects +- Status badges with color coding +- Custom buttons with hover effects +- Responsive tables +- Form inputs with focus states + +### JavaScript + +#### `portal_search.js` +Real-time search functionality: +- Debounced input handling (300ms delay) +- AJAX calls to search endpoints +- Dynamic table updates +- Search result highlighting + +#### `assessment_form.js` +Assessment form enhancements: +- Unsaved changes warning +- Auto-fill client name from first/last name +- Number input validation +- Form state tracking + +#### `signature_pad.js` +Digital signature capture: +- HTML5 Canvas-based drawing +- Touch and mouse event support +- Clear signature functionality +- Export to base64 PNG +- AJAX save to server + +--- + +## Email Templates + +### Case Assignment (`mail_template_case_assigned`) +**Trigger:** Authorizer assigned to a Sale Order +**Recipient:** Authorizer email +**Content:** Case details, client information, link to portal + +### Status Change (`mail_template_status_changed`) +**Trigger:** Sale Order state changes +**Recipient:** Assigned authorizer +**Content:** Previous and new status, case details + +### Assessment Complete - Authorizer (`mail_template_assessment_complete_authorizer`) +**Trigger:** Assessment completed +**Recipient:** Assigned authorizer +**Content:** Assessment details, measurements, signed documents + +### Assessment Complete - Client (`mail_template_assessment_complete_client`) +**Trigger:** Assessment completed +**Recipient:** Client email +**Content:** Confirmation, next steps, measurements summary + +### Document Uploaded (`mail_template_document_uploaded`) +**Trigger:** Document uploaded via portal +**Recipient:** Internal team +**Content:** Document details, revision info, download link + +--- + +## User Guide + +### For Administrators + +#### Granting Portal Access + +1. Go to **Contacts** > Select the contact +2. Navigate to the **Portal Access** tab +3. Enable the appropriate role: + - Check `Is Authorizer` for OTs/Therapists + - Check `Is Sales Rep (Portal)` for Sales Reps +4. Click **Grant Portal Access** +5. The user receives an email with login instructions + +#### Assigning Cases to Authorizers + +1. Open a **Sale Order** +2. Set the **Authorizer** field to the appropriate contact +3. Save the order +4. The authorizer receives a notification email +5. The case appears in their portal dashboard + +--- + +### For Authorizers + +#### Accessing the Portal + +1. Visit `https://your-domain.com/my` +2. Log in with your portal credentials +3. Click **Authorizer Portal** in the menu + +#### Viewing Cases + +1. From the dashboard, view recent cases and statistics +2. Click **View All Cases** or **My Cases** for the full list +3. Use the search bar to find specific cases by: + - Client name + - Client reference 1 or 2 + - Claim number + +#### Adding Comments + +1. Open a case detail view +2. Scroll to the Comments section +3. Enter your comment +4. Select comment type (General, Question, Update, Approval) +5. Click **Add Comment** + +#### Uploading Documents + +1. Open a case detail view +2. Go to the Documents section +3. Click **Upload Document** +4. Select document type (Full Application, Page 11, Page 12, etc.) +5. Choose the file and add revision notes +6. Click **Upload** + +--- + +### For Sales Representatives + +#### Starting a New Assessment + +1. Log in to the portal +2. Click **New Assessment** +3. Fill in client information: + - Name, address, contact details + - Client references +4. Record wheelchair specifications: + - Measurements (seat width, depth, height) + - Product types (cushion, backrest, frame, wheels) +5. Document accessibility and mobility needs +6. Click **Save & Continue** + +#### Capturing Signatures + +1. After saving assessment data, click **Proceed to Signatures** +2. **Page 11 (Authorizer):** + - Have the OT sign on the canvas + - Enter their printed name + - Click **Save Signature** +3. **Page 12 (Client):** + - Have the client sign on the canvas + - Enter their printed name + - Click **Save Signature** + +#### Completing the Assessment + +1. Once both signatures are captured, click **Complete Assessment** +2. The system will: + - Create a new customer record (if needed) + - Generate a draft Sale Order + - Attach signed documents + - Send notification emails +3. The assessment moves to "Completed" status + +--- + +## API Reference + +### Assessment Model Methods + +```python +# Complete an assessment and create Sale Order +assessment.action_complete() + +# Get formatted specifications for order notes +specs = assessment._format_specifications_for_order() + +# Ensure partner exists or create new +partner = assessment._ensure_partner() +``` + +### Sale Order Portal Methods + +```python +# Get safe data for portal display (no costs) +data = order.get_portal_display_data() + +# Search cases for authorizer +cases = SaleOrder.get_authorizer_portal_cases( + partner_id=123, + search_query='Smith', + limit=50, + offset=0 +) + +# Search cases for sales rep +cases = SaleOrder.get_sales_rep_portal_cases( + user_id=456, + search_query='wheelchair', + limit=50, + offset=0 +) +``` + +### Partner Methods + +```python +# Grant portal access programmatically +partner.action_grant_portal_access() + +# Check if partner is an authorizer +if partner.is_authorizer: + cases = partner.assigned_case_count +``` + +### Document Methods + +```python +# Get all documents for an order +docs = ADPDocument.get_documents_for_order(sale_order_id) + +# Get revision history +history = document.get_revision_history() +``` + +--- + +## Troubleshooting + +### Common Errors + +#### Error: `Invalid field 'in_portal' in 'portal.wizard.user'` + +**Cause:** Odoo 19 changed the portal wizard API, removing the `in_portal` field. + +**Solution:** The `action_grant_portal_access` method has been updated to: +1. First attempt using the standard portal wizard +2. If that fails, fall back to direct user creation with portal group assignment + +```python +# The fallback code creates the user directly: +portal_group = self.env.ref('base.group_portal') +portal_user = self.env['res.users'].sudo().create({ + 'name': self.name, + 'login': self.email, + 'email': self.email, + 'partner_id': self.id, + 'groups_id': [(6, 0, [portal_group.id])], +}) +``` + +--- + +#### Error: `Invalid view type: 'tree'` + +**Cause:** Odoo 19 renamed `` views to ``. + +**Solution:** Replace all `` tags with `` in XML view definitions: +```xml + +... + + +... +``` + +--- + +#### Error: `Invalid field 'category_id' in 'res.groups'` + +**Cause:** Odoo 19 no longer supports `category_id` in `res.groups` XML definitions. + +**Solution:** Remove the `` element from security group definitions: +```xml + + +``` + +--- + +#### Error: `DeprecationWarning: @route(type='json') is deprecated` + +**Cause:** Odoo 19 uses `type='jsonrpc'` instead of `type='json'`. + +**Solution:** Update route decorators: +```python +# Old +@http.route('/my/endpoint', type='json', auth='user') + +# New +@http.route('/my/endpoint', type='jsonrpc', auth='user') +``` + +--- + +### Portal Access Issues + +#### User can't see cases in portal + +1. Verify the partner has `is_authorizer` or `is_sales_rep_portal` checked +2. Verify the `authorizer_portal_user_id` is set +3. For authorizers, verify the Sale Order has `x_fc_authorizer_id` set to their partner ID +4. For sales reps, verify the Sale Order has `user_id` set to their user ID + +#### Email notifications not sending + +1. Check that the outgoing mail server is configured in Odoo +2. Verify the email templates exist and are active +3. Check the mail queue (Settings > Technical > Email > Emails) +4. Review the Odoo logs for mail errors + +--- + +### Debug Logging + +Enable debug logging for this module: + +```python +import logging +_logger = logging.getLogger('fusion_authorizer_portal') +_logger.setLevel(logging.DEBUG) +``` + +Or in Odoo configuration: +```ini +[options] +log_handler = fusion_authorizer_portal:DEBUG +``` + +--- + +## Changelog + +### Version 19.0.1.0.0 (Initial Release) + +**New Features:** +- Authorizer Portal with case management +- Sales Rep Portal with assessment forms +- Wheelchair Assessment model with 50+ fields +- Digital signature capture (Pages 11 & 12) +- Document management with revision tracking +- Real-time search functionality +- Email notifications for key events +- Portal access management from partner form + +**Technical:** +- Compatible with Odoo Enterprise v19 +- Integrates with fusion_claims module +- Mobile-responsive portal design +- Touch-friendly signature pad +- AJAX-powered search + +**Bug Fixes:** +- Fixed `in_portal` field error in Odoo 19 portal wizard +- Fixed `tree` to `list` view type for Odoo 19 +- Fixed `category_id` error in security groups +- Fixed `type='json'` deprecation warning + +--- + +## File Structure + +``` +fusion_authorizer_portal/ +├── __init__.py +├── __manifest__.py +├── README.md +├── controllers/ +│ ├── __init__.py +│ ├── portal_main.py # Authorizer & Sales Rep portal routes +│ └── portal_assessment.py # Assessment routes +├── data/ +│ ├── mail_template_data.xml # Email templates & sequences +│ └── portal_menu_data.xml # Portal menu items +├── models/ +│ ├── __init__.py +│ ├── adp_document.py # Document management model +│ ├── assessment.py # Assessment model +│ ├── authorizer_comment.py # Comments model +│ ├── res_partner.py # Partner extensions +│ └── sale_order.py # Sale Order extensions +├── security/ +│ ├── ir.model.access.csv # Access rights +│ └── portal_security.xml # Groups & record rules +├── static/ +│ └── src/ +│ ├── css/ +│ │ └── portal_style.css # Portal styling +│ └── js/ +│ ├── assessment_form.js # Form enhancements +│ ├── portal_search.js # Real-time search +│ └── signature_pad.js # Signature capture +└── views/ + ├── assessment_views.xml # Assessment backend views + ├── portal_templates.xml # Portal QWeb templates + ├── res_partner_views.xml # Partner form extensions + └── sale_order_views.xml # Sale Order extensions +``` + +--- + +## Support + +For support or feature requests, contact: + +- **Email:** support@fusionclaims.com +- **Website:** https://fusionclaims.com + +--- + +*Last Updated: January 2026* diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/__init__.py b/fusion_authorizer_portal/fusion_authorizer_portal/__init__.py new file mode 100644 index 0000000..c3d410e --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import controllers diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/__manifest__.py b/fusion_authorizer_portal/fusion_authorizer_portal/__manifest__.py new file mode 100644 index 0000000..283339b --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/__manifest__.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Fusion Authorizer & Sales Portal', + 'version': '19.0.2.0.9', + 'category': 'Sales/Portal', + 'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms', + 'description': """ +Fusion Authorizer & Sales Rep Portal +===================================== + +This module provides external portal access for: + +**Authorizers (Occupational Therapists)** +- View assigned ADP cases +- Upload documents (ADP applications, signed pages) +- Add comments to cases +- Complete assessments with clients +- Capture digital signatures for ADP pages 11 & 12 + +**Sales Representatives** +- View their sales cases +- Start new client assessments +- Record wheelchair specifications and measurements +- Capture client signatures +- Track assessment progress + +**Assessment System** +- Client information collection +- Wheelchair specifications (seat width, depth, height, etc.) +- Accessibility and mobility needs documentation +- Digital signature capture for ADP pages 11 & 12 +- Automatic draft Sale Order creation +- Document distribution to all parties +- Automated email notifications + +**Features** +- Real-time client search +- Document version tracking +- Mobile-friendly signature capture +- Email notifications for status changes +- Secure portal access with role-based permissions + """, + 'author': 'Fusion Claims', + 'website': 'https://fusionclaims.com', + 'license': 'LGPL-3', + 'depends': [ + 'base', + 'sale', + 'portal', + 'website', + 'mail', + 'calendar', + 'knowledge', + 'fusion_claims', + ], + 'data': [ + # Security + 'security/portal_security.xml', + 'security/ir.model.access.csv', + # Data + 'data/mail_template_data.xml', + 'data/portal_menu_data.xml', + 'data/ir_actions_server_data.xml', + 'data/welcome_articles.xml', + # Views + 'views/res_partner_views.xml', + 'views/sale_order_views.xml', + 'views/assessment_views.xml', + 'views/pdf_template_views.xml', + # Portal Templates + 'views/portal_templates.xml', + 'views/portal_assessment_express.xml', + 'views/portal_pdf_editor.xml', + 'views/portal_accessibility_templates.xml', + 'views/portal_accessibility_forms.xml', + 'views/portal_technician_templates.xml', + 'views/portal_book_assessment.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'fusion_authorizer_portal/static/src/xml/chatter_message_authorizer.xml', + 'fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js', + ], + 'web.assets_frontend': [ + 'fusion_authorizer_portal/static/src/css/portal_style.css', + 'fusion_authorizer_portal/static/src/css/technician_portal.css', + 'fusion_authorizer_portal/static/src/js/portal_search.js', + 'fusion_authorizer_portal/static/src/js/assessment_form.js', + 'fusion_authorizer_portal/static/src/js/signature_pad.js', + 'fusion_authorizer_portal/static/src/js/loaner_portal.js', + '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', + ], + }, + 'images': ['static/description/icon.png'], + 'installable': True, + 'application': False, + 'auto_install': False, +} diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/controllers/__init__.py b/fusion_authorizer_portal/fusion_authorizer_portal/controllers/__init__.py new file mode 100644 index 0000000..81b165c --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/controllers/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import portal_main +from . import portal_assessment +from . import pdf_editor \ No newline at end of file diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/controllers/pdf_editor.py b/fusion_authorizer_portal/fusion_authorizer_portal/controllers/pdf_editor.py new file mode 100644 index 0000000..9b3f098 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/controllers/pdf_editor.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +# Fusion PDF Field Editor Controller +# Provides routes for the visual drag-and-drop field position editor + +import base64 +import json +import logging + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class FusionPdfEditorController(http.Controller): + """Controller for the PDF field position visual editor.""" + + # ================================================================ + # Editor Page + # ================================================================ + + @http.route('/fusion/pdf-editor/', type='http', auth='user', website=True) + def pdf_field_editor(self, template_id, **kw): + """Render the visual field editor for a PDF template.""" + template = request.env['fusion.pdf.template'].browse(template_id) + if not template.exists(): + return request.redirect('/web') + + # Get preview image for page 1 + preview_url = '' + preview = template.preview_ids.filtered(lambda p: p.page == 1) + if preview and preview[0].image: + preview_url = f'/web/image/fusion.pdf.template.preview/{preview[0].id}/image' + + fields = template.field_ids.read([ + 'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height', + 'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active', + 'text_align', + ]) + + return request.render('fusion_authorizer_portal.portal_pdf_field_editor', { + 'template': template, + 'fields': fields, + 'preview_url': preview_url, + }) + + # ================================================================ + # JSONRPC: Get fields for template + # ================================================================ + + @http.route('/fusion/pdf-editor/fields', type='json', auth='user') + def get_fields(self, template_id, **kw): + """Return all fields for a template.""" + template = request.env['fusion.pdf.template'].browse(template_id) + if not template.exists(): + return [] + return template.field_ids.read([ + 'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height', + 'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active', + 'text_align', + ]) + + # ================================================================ + # JSONRPC: Update field position/properties + # ================================================================ + + @http.route('/fusion/pdf-editor/update-field', type='json', auth='user') + def update_field(self, field_id, values, **kw): + """Update a field's position or properties.""" + field = request.env['fusion.pdf.template.field'].browse(field_id) + if not field.exists(): + return {'error': 'Field not found'} + + # Filter to allowed fields only + allowed = { + 'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height', + 'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active', + 'text_align', + } + safe_values = {k: v for k, v in values.items() if k in allowed} + if safe_values: + field.write(safe_values) + return {'success': True} + + # ================================================================ + # JSONRPC: Create new field + # ================================================================ + + @http.route('/fusion/pdf-editor/create-field', type='json', auth='user') + def create_field(self, **kw): + """Create a new field on a template.""" + template_id = kw.get('template_id') + if not template_id: + return {'error': 'Missing template_id'} + + vals = { + 'template_id': int(template_id), + 'name': kw.get('name', 'new_field'), + 'label': kw.get('label', 'New Field'), + 'field_type': kw.get('field_type', 'text'), + 'field_key': kw.get('field_key', kw.get('name', '')), + 'page': int(kw.get('page', 1)), + 'pos_x': float(kw.get('pos_x', 0.3)), + 'pos_y': float(kw.get('pos_y', 0.3)), + 'width': float(kw.get('width', 0.150)), + 'height': float(kw.get('height', 0.015)), + 'font_size': float(kw.get('font_size', 10)), + } + + field = request.env['fusion.pdf.template.field'].create(vals) + return {'id': field.id, 'success': True} + + # ================================================================ + # JSONRPC: Delete field + # ================================================================ + + @http.route('/fusion/pdf-editor/delete-field', type='json', auth='user') + def delete_field(self, field_id, **kw): + """Delete a field from a template.""" + field = request.env['fusion.pdf.template.field'].browse(field_id) + if field.exists(): + field.unlink() + return {'success': True} + + # ================================================================ + # JSONRPC: Get page preview image URL + # ================================================================ + + @http.route('/fusion/pdf-editor/page-image', type='json', auth='user') + def get_page_image(self, template_id, page, **kw): + """Return the preview image URL for a specific page.""" + template = request.env['fusion.pdf.template'].browse(template_id) + if not template.exists(): + return {'image_url': ''} + + preview = template.preview_ids.filtered(lambda p: p.page == page) + if preview and preview[0].image: + return {'image_url': f'/web/image/fusion.pdf.template.preview/{preview[0].id}/image'} + return {'image_url': ''} + + # ================================================================ + # Upload page preview image (from editor) + # ================================================================ + + @http.route('/fusion/pdf-editor/upload-preview', type='http', auth='user', + methods=['POST'], csrf=True, website=True) + def upload_preview_image(self, **kw): + """Upload a preview image for a template page directly from the editor.""" + template_id = int(kw.get('template_id', 0)) + page = int(kw.get('page', 1)) + template = request.env['fusion.pdf.template'].browse(template_id) + if not template.exists(): + return json.dumps({'error': 'Template not found'}) + + image_file = request.httprequest.files.get('preview_image') + if not image_file: + return json.dumps({'error': 'No image uploaded'}) + + image_data = base64.b64encode(image_file.read()) + + # Find or create preview for this page + preview = template.preview_ids.filtered(lambda p: p.page == page) + if preview: + preview[0].write({'image': image_data, 'image_filename': image_file.filename}) + else: + request.env['fusion.pdf.template.preview'].create({ + 'template_id': template_id, + 'page': page, + 'image': image_data, + 'image_filename': image_file.filename, + }) + + _logger.info("Uploaded preview image for template %s page %d", template.name, page) + return request.redirect(f'/fusion/pdf-editor/{template_id}') + + # ================================================================ + # Preview: Generate sample filled PDF + # ================================================================ + + @http.route('/fusion/pdf-editor/preview/', type='http', auth='user') + def preview_pdf(self, template_id, **kw): + """Generate a preview filled PDF with sample data.""" + template = request.env['fusion.pdf.template'].browse(template_id) + if not template.exists() or not template.pdf_file: + return request.redirect('/web') + + # Build sample data for preview + sample_context = { + 'client_last_name': 'Smith', + 'client_first_name': 'John', + 'client_middle_name': 'A', + 'client_health_card': '1234-567-890', + 'client_health_card_version': 'AB', + 'client_street': '123 Main Street', + 'client_unit': 'Unit 4B', + 'client_city': 'Toronto', + 'client_state': 'Ontario', + 'client_postal_code': 'M5V 2T6', + 'client_phone': '(416) 555-0123', + 'client_email': 'john.smith@example.com', + 'client_weight': '185', + 'consent_applicant': True, + 'consent_agent': False, + 'consent_date': '2026-02-08', + 'agent_last_name': '', + 'agent_first_name': '', + } + + try: + pdf_bytes = template.generate_filled_pdf(sample_context) + headers = [ + ('Content-Type', 'application/pdf'), + ('Content-Disposition', f'inline; filename="preview_{template.name}.pdf"'), + ] + return request.make_response(pdf_bytes, headers=headers) + except Exception as e: + _logger.error("PDF preview generation failed: %s", e) + return request.redirect(f'/fusion/pdf-editor/{template_id}?error=preview_failed') diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/controllers/portal_assessment.py b/fusion_authorizer_portal/fusion_authorizer_portal/controllers/portal_assessment.py new file mode 100644 index 0000000..b69c431 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/controllers/portal_assessment.py @@ -0,0 +1,1443 @@ +# -*- coding: utf-8 -*- + +from odoo import http, fields, _ +from odoo.http import request +from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager +from odoo.exceptions import AccessError, MissingError, ValidationError +import json +import base64 +import logging +from datetime import datetime +from markupsafe import Markup + +_logger = logging.getLogger(__name__) + + +class AssessmentPortal(CustomerPortal): + """Portal controller for Assessments""" + + @http.route(['/my/assessments', '/my/assessments/page/'], type='http', auth='user', website=True) + def portal_assessments(self, page=1, search='', state='', sortby='date', **kw): + """List of assessments""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_authorizer and not partner.is_sales_rep_portal: + return request.redirect('/my') + + Assessment = request.env['fusion.assessment'].sudo() + + # Build domain based on role + domain = [] + if partner.is_authorizer and partner.is_sales_rep_portal: + domain = ['|', ('authorizer_id', '=', partner.id), ('sales_rep_id', '=', user.id)] + elif partner.is_authorizer: + domain = [('authorizer_id', '=', partner.id)] + elif partner.is_sales_rep_portal: + domain = [('sales_rep_id', '=', user.id)] + + # Add state filter + if state: + domain.append(('state', '=', state)) + + # Add search filter + if search: + domain = domain + [ + '|', '|', + ('client_name', 'ilike', search), + ('reference', 'ilike', search), + ('client_email', 'ilike', search), + ] + + # Sorting + sortings = { + 'date': {'label': _('Date'), 'order': 'assessment_date desc'}, + 'name': {'label': _('Client'), 'order': 'client_name'}, + 'reference': {'label': _('Reference'), 'order': 'reference'}, + 'state': {'label': _('Status'), 'order': 'state'}, + } + order = sortings.get(sortby, sortings['date'])['order'] + + # Pager + assessment_count = Assessment.search_count(domain) + pager = portal_pager( + url='/my/assessments', + url_args={'search': search, 'state': state, 'sortby': sortby}, + total=assessment_count, + page=page, + step=20, + ) + + # Get assessments + assessments = Assessment.search(domain, order=order, limit=20, offset=pager['offset']) + + # State options for filter + state_options = [ + ('', _('All')), + ('draft', _('In Progress')), + ('pending_signature', _('Pending Signatures')), + ('completed', _('Completed')), + ('cancelled', _('Cancelled')), + ] + + values = { + 'assessments': assessments, + 'pager': pager, + 'search': search, + 'state': state, + 'state_options': state_options, + 'sortby': sortby, + 'sortings': sortings, + 'page_name': 'assessments', + } + + return request.render('fusion_authorizer_portal.portal_assessments', values) + + @http.route('/my/assessment/new', type='http', auth='user', website=True) + def portal_assessment_new(self, **kw): + """Start a new assessment""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_authorizer and not partner.is_sales_rep_portal: + return request.redirect('/my') + + # Get list of authorizers for dropdown (if sales rep starting assessment) + authorizers = request.env['res.partner'].sudo().search([ + ('is_authorizer', '=', True), + ]) + + values = { + 'partner': partner, + 'user': user, + 'authorizers': authorizers, + 'countries': request.env['res.country'].sudo().search([]), + 'default_country': request.env.ref('base.ca', raise_if_not_found=False), + 'page_name': 'assessment_new', + } + + return request.render('fusion_authorizer_portal.portal_assessment_form', values) + + @http.route('/my/assessment/', type='http', auth='user', website=True) + def portal_assessment_view(self, assessment_id, **kw): + """View/edit an assessment""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_authorizer and not partner.is_sales_rep_portal: + return request.redirect('/my') + + try: + assessment = request.env['fusion.assessment'].sudo().browse(assessment_id) + if not assessment.exists(): + raise MissingError(_('Assessment not found.')) + + # Check access + has_access = ( + (partner.is_authorizer and assessment.authorizer_id.id == partner.id) or + (partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id) + ) + if not has_access: + raise AccessError(_('You do not have access to this assessment.')) + + except (AccessError, MissingError): + return request.redirect('/my/assessments') + + # Get list of authorizers for dropdown + authorizers = request.env['res.partner'].sudo().search([ + ('is_authorizer', '=', True), + ]) + + # Get assessment photos + photos = request.env['ir.attachment'].sudo().search([ + ('res_model', '=', 'fusion.assessment'), + ('res_id', '=', assessment.id), + ('mimetype', 'like', 'image/%'), + ]) + + values = { + 'assessment': assessment, + 'partner': partner, + 'user': user, + 'authorizers': authorizers, + 'countries': request.env['res.country'].sudo().search([]), + 'page_name': 'assessment_edit', + 'is_readonly': assessment.state in ['completed', 'cancelled'], + 'photos': photos, + } + + return request.render('fusion_authorizer_portal.portal_assessment_form', values) + + @http.route('/my/assessment/save', type='http', auth='user', website=True, methods=['POST'], csrf=True) + def portal_assessment_save(self, assessment_id=None, **kw): + """Save assessment data (create or update)""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_authorizer and not partner.is_sales_rep_portal: + return request.redirect('/my') + + Assessment = request.env['fusion.assessment'].sudo() + + # Prepare values + vals = { + 'client_name': kw.get('client_name', ''), + 'client_first_name': kw.get('client_first_name', ''), + 'client_last_name': kw.get('client_last_name', ''), + 'client_street': kw.get('client_street', ''), + 'client_unit': kw.get('client_unit', ''), + 'client_city': kw.get('client_city', ''), + 'client_state': kw.get('client_state', 'Ontario'), + 'client_postal_code': kw.get('client_postal_code', ''), + 'client_phone': kw.get('client_phone', ''), + 'client_mobile': kw.get('client_mobile', ''), + 'client_email': kw.get('client_email', ''), + 'client_reference_1': kw.get('client_reference_1', ''), + 'client_reference_2': kw.get('client_reference_2', ''), + 'assessment_location': kw.get('assessment_location', 'home'), + 'assessment_location_notes': kw.get('assessment_location_notes', ''), + } + + # Wheelchair specifications + float_fields = [ + 'seat_width', 'seat_depth', 'seat_to_floor_height', 'back_height', + 'armrest_height', 'footrest_length', 'overall_width', 'overall_length', + 'overall_height', 'seat_angle', 'back_angle', 'client_weight', 'client_height' + ] + for field in float_fields: + if kw.get(field): + try: + vals[field] = float(kw.get(field)) + except (ValueError, TypeError): + pass + + # Selection fields + selection_fields = ['cushion_type', 'backrest_type', 'frame_type', 'wheel_type'] + for field in selection_fields: + if kw.get(field): + vals[field] = kw.get(field) + + # Text fields + text_fields = ['cushion_notes', 'backrest_notes', 'frame_notes', 'wheel_notes', + 'mobility_notes', 'accessibility_notes', 'special_requirements', 'diagnosis'] + for field in text_fields: + if kw.get(field): + vals[field] = kw.get(field) + + # Authorizer + if kw.get('authorizer_id'): + try: + vals['authorizer_id'] = int(kw.get('authorizer_id')) + except (ValueError, TypeError): + pass + + # Country + if kw.get('client_country_id'): + try: + vals['client_country_id'] = int(kw.get('client_country_id')) + except (ValueError, TypeError): + pass + + try: + if assessment_id and assessment_id != 'None': + # Update existing + assessment = Assessment.browse(int(assessment_id)) + if not assessment.exists(): + raise MissingError(_('Assessment not found.')) + + # Check access + has_access = ( + (partner.is_authorizer and assessment.authorizer_id.id == partner.id) or + (partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id) + ) + if not has_access: + raise AccessError(_('You do not have access to this assessment.')) + + if assessment.state not in ['draft', 'pending_signature']: + raise ValidationError(_('Cannot modify a completed or cancelled assessment.')) + + assessment.write(vals) + _logger.info(f"Updated assessment {assessment.reference}") + + else: + # Create new + vals['sales_rep_id'] = user.id + if partner.is_authorizer: + vals['authorizer_id'] = partner.id + + assessment = Assessment.create(vals) + _logger.info(f"Created new assessment {assessment.reference}") + + # Redirect based on action + action = kw.get('action', 'save') + if action == 'save_signatures': + return request.redirect(f'/my/assessment/{assessment.id}/signatures') + elif action == 'save_exit': + return request.redirect('/my/assessments') + else: + return request.redirect(f'/my/assessment/{assessment.id}') + + except Exception as e: + _logger.error(f"Error saving assessment: {e}") + return request.redirect('/my/assessments') + + @http.route('/my/assessment//signatures', type='http', auth='user', website=True) + def portal_assessment_signatures(self, assessment_id, **kw): + """Signature capture page""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_authorizer and not partner.is_sales_rep_portal: + return request.redirect('/my') + + try: + assessment = request.env['fusion.assessment'].sudo().browse(assessment_id) + if not assessment.exists(): + raise MissingError(_('Assessment not found.')) + + # Check access + has_access = ( + (partner.is_authorizer and assessment.authorizer_id.id == partner.id) or + (partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id) + ) + if not has_access: + raise AccessError(_('You do not have access to this assessment.')) + + except (AccessError, MissingError): + return request.redirect('/my/assessments') + + values = { + 'assessment': assessment, + 'partner': partner, + 'page_name': 'assessment_signatures', + } + + return request.render('fusion_authorizer_portal.portal_assessment_signatures', values) + + @http.route('/my/assessment//save_signature', type='jsonrpc', auth='user') + def portal_save_signature(self, assessment_id, signature_type='', signature_data='', signer_name='', **kw): + """Save a signature (AJAX)""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_authorizer and not partner.is_sales_rep_portal: + return {'success': False, 'error': 'Access denied'} + + try: + assessment = request.env['fusion.assessment'].sudo().browse(assessment_id) + if not assessment.exists(): + return {'success': False, 'error': 'Assessment not found'} + + # Check access + has_access = ( + (partner.is_authorizer and assessment.authorizer_id.id == partner.id) or + (partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id) + ) + if not has_access: + return {'success': False, 'error': 'Access denied'} + + if not signature_data: + return {'success': False, 'error': 'No signature data provided'} + + # Remove data URL prefix if present + if signature_data.startswith('data:image'): + signature_data = signature_data.split(',')[1] + + vals = {} + if signature_type == 'page_11': + vals = { + 'signature_page_11': signature_data, + 'signature_page_11_name': signer_name, + 'signature_page_11_date': datetime.now(), + } + elif signature_type == 'page_12': + vals = { + 'signature_page_12': signature_data, + 'signature_page_12_name': signer_name, + 'signature_page_12_date': datetime.now(), + } + else: + return {'success': False, 'error': 'Invalid signature type'} + + assessment.write(vals) + + # Update state if needed + if assessment.state == 'draft': + assessment.state = 'pending_signature' + + return { + 'success': True, + 'signatures_complete': assessment.signatures_complete, + } + + except Exception as e: + _logger.error(f"Error saving signature: {e}") + return {'success': False, 'error': str(e)} + + @http.route('/my/assessment//complete', type='http', auth='user', website=True, methods=['POST'], csrf=True) + def portal_assessment_complete(self, assessment_id, **kw): + """Complete the assessment""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_authorizer and not partner.is_sales_rep_portal: + return request.redirect('/my') + + try: + assessment = request.env['fusion.assessment'].sudo().browse(assessment_id) + if not assessment.exists(): + raise MissingError(_('Assessment not found.')) + + # Check access + has_access = ( + (partner.is_authorizer and assessment.authorizer_id.id == partner.id) or + (partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id) + ) + if not has_access: + raise AccessError(_('You do not have access to this assessment.')) + + # Complete the assessment + result = assessment.action_complete() + + # Redirect to the created sale order or assessments list + if assessment.sale_order_id: + return request.redirect('/my/assessments?message=completed') + else: + return request.redirect('/my/assessments') + + except ValidationError as e: + _logger.warning(f"Validation error completing assessment: {e}") + return request.redirect(f'/my/assessment/{assessment_id}/signatures?error=signatures_required') + except Exception as e: + _logger.error(f"Error completing assessment: {e}") + return request.redirect(f'/my/assessment/{assessment_id}?error=1') + + # ========================================================================== + # EXPRESS ASSESSMENT FORM ROUTES + # ========================================================================== + + @http.route('/my/assessment/express', type='http', auth='user', website=True) + def portal_assessment_express_new(self, **kw): + """Start a new express assessment (Page 1 - Equipment Selection)""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_sales_rep_portal: + return request.redirect('/my') + + # Get list of authorizers for dropdown + authorizers = request.env['res.partner'].sudo().search([ + ('is_authorizer', '=', True), + ], order='name') + + # JSON-safe authorizer list for searchable dropdown (Markup so t-out won't escape) + authorizers_json = Markup(json.dumps([ + {'id': a.id, 'name': a.name, 'email': a.email or ''} + for a in authorizers + ])) + + # Get existing clients for dropdown + clients = request.env['res.partner'].sudo().search([ + ('customer_rank', '>', 0), + ], order='name', limit=500) + + # Get Google Maps API key + ICP = request.env['ir.config_parameter'].sudo() + google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '') + + values = { + 'partner': partner, + 'user': user, + 'authorizers': authorizers, + 'authorizers_json': authorizers_json, + 'clients': clients, + 'countries': request.env['res.country'].sudo().search([]), + 'provinces': self._get_canadian_provinces(), + 'default_country': request.env.ref('base.ca', raise_if_not_found=False), + 'page_name': 'assessment_express', + 'current_page': 1, + 'total_pages': 2, + 'assessment': None, + 'google_maps_api_key': google_maps_api_key, + } + + return request.render('fusion_authorizer_portal.portal_assessment_express', values) + + @http.route('/my/assessment/express/', type='http', auth='user', website=True) + def portal_assessment_express_edit(self, assessment_id, page=1, **kw): + """Continue/edit an express assessment""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_sales_rep_portal: + return request.redirect('/my') + + try: + assessment = request.env['fusion.assessment'].sudo().browse(assessment_id) + if not assessment.exists(): + raise MissingError(_('Assessment not found.')) + + # Check access - must be the sales rep who created it + if assessment.sales_rep_id.id != user.id: + raise AccessError(_('You do not have access to this assessment.')) + + if assessment.state in ['cancelled']: + return request.redirect('/my/assessments') + + except (AccessError, MissingError): + return request.redirect('/my/assessments') + + # Get list of authorizers for dropdown + authorizers = request.env['res.partner'].sudo().search([ + ('is_authorizer', '=', True), + ], order='name') + + # JSON-safe authorizer list for searchable dropdown (Markup so t-out won't escape) + authorizers_json = Markup(json.dumps([ + {'id': a.id, 'name': a.name, 'email': a.email or ''} + for a in authorizers + ])) + + # Get existing clients for dropdown + clients = request.env['res.partner'].sudo().search([ + ('customer_rank', '>', 0), + ], order='name', limit=500) + + try: + current_page = int(page) + except (ValueError, TypeError): + current_page = 1 + + # Get Google Maps API key + ICP = request.env['ir.config_parameter'].sudo() + google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '') + + values = { + 'partner': partner, + 'user': user, + 'assessment': assessment, + 'authorizers': authorizers, + 'authorizers_json': authorizers_json, + 'clients': clients, + 'countries': request.env['res.country'].sudo().search([]), + 'provinces': self._get_canadian_provinces(), + 'default_country': request.env.ref('base.ca', raise_if_not_found=False), + 'page_name': 'assessment_express', + 'current_page': current_page, + 'total_pages': 2, + 'google_maps_api_key': google_maps_api_key, + } + + return request.render('fusion_authorizer_portal.portal_assessment_express', values) + + @http.route('/my/assessment/express/save', type='http', auth='user', website=True, methods=['POST'], csrf=True) + def portal_assessment_express_save(self, **kw): + """Save express assessment data (create or update)""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_sales_rep_portal: + return request.redirect('/my') + + Assessment = request.env['fusion.assessment'].sudo() + assessment_id = kw.get('assessment_id') + current_page = int(kw.get('current_page', 1)) + action = kw.get('action', 'next') # next, back, save, submit + + # Build values from form + vals = self._build_express_assessment_vals(kw) + + try: + if assessment_id and assessment_id != 'None' and assessment_id != '': + # Update existing + assessment = Assessment.browse(int(assessment_id)) + if not assessment.exists(): + raise MissingError(_('Assessment not found.')) + + if assessment.sales_rep_id.id != user.id: + raise AccessError(_('You do not have access to this assessment.')) + + if assessment.state == 'completed': + # Allow updating ONLY consent/signature fields on completed assessments + consent_fields = { + 'consent_signed_by', 'consent_declaration_accepted', 'consent_date', + 'agent_relationship', 'agent_first_name', 'agent_last_name', + 'agent_middle_initial', 'agent_unit', 'agent_street_number', + 'agent_street_name', 'agent_city', 'agent_province', + 'agent_postal_code', 'agent_home_phone', 'agent_business_phone', + 'agent_phone_ext', + } + consent_vals = {k: v for k, v in vals.items() if k in consent_fields} + if consent_vals: + assessment.write(consent_vals) + _logger.info(f"Updated consent fields on completed assessment {assessment.reference}") + elif assessment.state == 'cancelled': + raise ValidationError(_('Cannot modify a cancelled assessment.')) + else: + # Draft - allow full update + assessment.write(vals) + _logger.info(f"Updated express assessment {assessment.reference}") + else: + # Create new + vals['sales_rep_id'] = user.id + vals['state'] = 'draft' + assessment = Assessment.create(vals) + _logger.info(f"Created new express assessment {assessment.reference}") + + # Handle photo uploads + uploaded_photos = request.httprequest.files.getlist('assessment_photos') + if uploaded_photos: + for photo_file in uploaded_photos: + if photo_file and photo_file.filename: + try: + file_content = photo_file.read() + file_base64 = base64.b64encode(file_content) + + # Create attachment linked to assessment + attachment = request.env['ir.attachment'].sudo().create({ + 'name': photo_file.filename, + 'type': 'binary', + 'datas': file_base64, + 'res_model': 'fusion.assessment', + 'res_id': assessment.id, + 'mimetype': photo_file.content_type or 'image/jpeg', + }) + _logger.info(f"Uploaded assessment photo: {photo_file.filename}") + except Exception as e: + _logger.error(f"Error uploading photo {photo_file.filename}: {e}") + + # ===== Handle Page 11 signature capture ===== + signature_data = kw.get('signature_page_11_data', '') + if signature_data and signature_data.startswith('data:image/'): + try: + # Strip data URL prefix: "data:image/png;base64,..." + sig_base64 = signature_data.split(',', 1)[1] + sig_vals = { + 'signature_page_11': sig_base64, + 'signature_page_11_date': fields.Datetime.now(), + } + # Set signer name + if kw.get('consent_signed_by') == 'agent' and kw.get('agent_first_name'): + sig_vals['signature_page_11_name'] = ( + f"{kw.get('agent_first_name', '')} {kw.get('agent_last_name', '')}" + ).strip() + else: + sig_vals['signature_page_11_name'] = ( + f"{kw.get('client_first_name', '')} {kw.get('client_last_name', '')}" + ).strip() + assessment.write(sig_vals) + _logger.info(f"Saved Page 11 signature for assessment {assessment.reference}") + except Exception as e: + _logger.error(f"Error saving Page 11 signature: {e}") + + # Handle navigation + if action == 'submit': + # If already completed, we just saved consent/signature above -- redirect with success + if assessment.state == 'completed': + # Generate filled PDF if signature was added + if assessment.signature_page_11 and assessment.consent_declaration_accepted: + try: + pdf_bytes = assessment.generate_template_pdf('Page 11') + if pdf_bytes: + import base64 as b64 + assessment.write({ + 'signed_page_11_pdf': b64.b64encode(pdf_bytes), + 'signed_page_11_pdf_filename': f'ADP_Page11_{assessment.reference}.pdf', + }) + # Update sale order too + # Issue 8 fix: bypass document lock since this is a portal + # re-signing on an already-completed assessment (SO may have + # progressed past 'submitted' where the lock kicks in) + if assessment.sale_order_id: + assessment.sale_order_id.with_context( + skip_document_lock_validation=True + ).write({ + 'x_fc_signed_pages_11_12': b64.b64encode(pdf_bytes), + 'x_fc_signed_pages_filename': f'ADP_Page11_{assessment.reference}.pdf', + }) + _logger.info(f"Generated Page 11 PDF for completed assessment {assessment.reference}") + except Exception as pdf_e: + _logger.warning(f"PDF generation failed (non-blocking): {pdf_e}") + + # Post consent & signature info to sale order chatter + if assessment.sale_order_id and assessment.signature_page_11: + try: + from markupsafe import Markup + signer = assessment.signature_page_11_name or 'Unknown' + signed_by = 'Applicant' if assessment.consent_signed_by == 'applicant' else 'Agent' + consent_date = str(assessment.consent_date) if assessment.consent_date else 'N/A' + + # Create signature as attachment + sig_att = request.env['ir.attachment'].sudo().create({ + 'name': f'Page11_Signature_{assessment.reference}.png', + 'type': 'binary', + 'datas': assessment.signature_page_11, + 'res_model': 'sale.order', + 'res_id': assessment.sale_order_id.id, + 'mimetype': 'image/png', + }) + + body = Markup( + '' + ) + assessment.sale_order_id.message_post( + body=body, + message_type='comment', + subtype_xmlid='mail.mt_note', + attachment_ids=[sig_att.id], + ) + _logger.info(f"Posted Page 11 consent info to SO {assessment.sale_order_id.name}") + except Exception as chat_e: + _logger.warning(f"Failed to post consent to chatter: {chat_e}") + + so_id = assessment.sale_order_id.id if assessment.sale_order_id else '' + return request.redirect(f'/my/assessments?message=completed&so={so_id}') + + # Complete the express assessment + try: + sale_order = assessment.action_complete_express() + + # Post assessment photos to sale order chatter + photo_attachments = request.env['ir.attachment'].sudo().search([ + ('res_model', '=', 'fusion.assessment'), + ('res_id', '=', assessment.id), + ('mimetype', 'like', 'image/%'), + ]) + if photo_attachments: + # Copy attachments to sale order + attachment_ids = [] + for att in photo_attachments: + new_att = att.copy({ + 'res_model': 'sale.order', + 'res_id': sale_order.id, + }) + attachment_ids.append(new_att.id) + + # Post message to chatter with photos + sale_order.message_post( + body=f"

Assessment Photos
Photos from assessment {assessment.reference} by {request.env.user.name}

", + message_type='comment', + subtype_xmlid='mail.mt_comment', + attachment_ids=attachment_ids, + ) + _logger.info(f"Posted {len(attachment_ids)} assessment photos to sale order {sale_order.name}") + + # Process loaner checkout if loaner data was submitted + loaner_product_id = kw.get('loaner_product_id') + loaner_checkout_flag = kw.get('loaner_checkout', '0') + if loaner_product_id and loaner_checkout_flag == '1': + try: + loaner_vals = { + 'product_id': int(loaner_product_id), + 'sale_order_id': sale_order.id, + 'partner_id': sale_order.partner_id.id, + 'loaner_period_days': int(kw.get('loaner_period_days', 7)), + 'checkout_condition': kw.get('loaner_condition', 'good'), + 'checkout_notes': kw.get('loaner_notes', ''), + 'sales_rep_id': request.env.user.id, + } + if sale_order.x_fc_authorizer_id: + loaner_vals['authorizer_id'] = sale_order.x_fc_authorizer_id.id + if sale_order.partner_shipping_id: + loaner_vals['delivery_address'] = sale_order.partner_shipping_id.contact_address + loaner_lot = kw.get('loaner_lot_id') + if loaner_lot: + loaner_vals['lot_id'] = int(loaner_lot) + checkout = request.env['fusion.loaner.checkout'].sudo().create(loaner_vals) + checkout.action_checkout() + _logger.info(f"Created loaner checkout {checkout.name} for SO {sale_order.name}") + except Exception as le: + _logger.error(f"Error creating loaner checkout: {le}") + + # ===== Generate filled Page 11 PDF if signature exists ===== + if assessment.signature_page_11 and assessment.consent_declaration_accepted: + try: + pdf_bytes = assessment.generate_template_pdf('Page 11') + if pdf_bytes: + import base64 as b64 + assessment.write({ + 'signed_page_11_pdf': b64.b64encode(pdf_bytes), + 'signed_page_11_pdf_filename': f'ADP_Page11_{assessment.reference}.pdf', + }) + # Also store on sale order + # Issue 8 fix: bypass document lock for portal writes + sale_order.with_context( + skip_document_lock_validation=True + ).write({ + 'x_fc_signed_pages_11_12': b64.b64encode(pdf_bytes), + 'x_fc_signed_pages_filename': f'ADP_Page11_{assessment.reference}.pdf', + }) + _logger.info(f"Generated Page 11 PDF for assessment {assessment.reference}") + except Exception as pdf_e: + _logger.warning(f"PDF generation failed (non-blocking): {pdf_e}") + + return request.redirect(f'/my/assessments?message=completed&so={sale_order.id}') + except Exception as e: + _logger.error(f"Error completing express assessment: {e}") + return request.redirect(f'/my/assessment/express/{assessment.id}?error={str(e)}') + elif action == 'start_over': + # Cancel and start fresh + if assessment_id and assessment_id != 'None': + assessment.unlink() + return request.redirect('/my/assessment/express') + else: + # Just save + return request.redirect(f'/my/assessment/express/{assessment.id}?page={current_page}') + + except Exception as e: + _logger.error(f"Error saving express assessment: {e}") + if assessment_id and assessment_id != 'None': + return request.redirect(f'/my/assessment/express/{assessment_id}?page={current_page}&error=1') + return request.redirect('/my/assessment/express?error=1') + + def _build_express_assessment_vals(self, kw): + """Build values dict from express form POST data""" + vals = {} + + # Equipment type + if kw.get('equipment_type'): + vals['equipment_type'] = kw.get('equipment_type') + + # Equipment sub-types + if kw.get('rollator_type'): + vals['rollator_type'] = kw.get('rollator_type') + if kw.get('wheelchair_type'): + vals['wheelchair_type'] = kw.get('wheelchair_type') + if kw.get('powerchair_type'): + vals['powerchair_type'] = kw.get('powerchair_type') + + # Float measurements + float_fields = [ + 'rollator_handle_height', 'rollator_seat_height', + 'seat_width', 'seat_depth', 'seat_to_floor_height', 'back_height', + 'legrest_length', 'cane_height', 'client_weight', + ] + for field in float_fields: + if kw.get(field): + try: + vals[field] = float(kw.get(field)) + except (ValueError, TypeError): + pass + + # Checkbox options - collect as comma-separated strings + # Rollator addons + rollator_addons = kw.getlist('rollator_addons') if hasattr(kw, 'getlist') else [] + if not rollator_addons and 'rollator_addons' in kw: + rollator_addons = [kw.get('rollator_addons')] if kw.get('rollator_addons') else [] + if rollator_addons: + vals['rollator_addons'] = ', '.join(rollator_addons) + + # Wheelchair options + frame_options = kw.getlist('frame_options') if hasattr(kw, 'getlist') else [] + if not frame_options and 'frame_options' in kw: + frame_options = [kw.get('frame_options')] if kw.get('frame_options') else [] + if frame_options: + vals['frame_options'] = ', '.join(frame_options) + + wheel_options = kw.getlist('wheel_options') if hasattr(kw, 'getlist') else [] + if not wheel_options and 'wheel_options' in kw: + wheel_options = [kw.get('wheel_options')] if kw.get('wheel_options') else [] + if wheel_options: + vals['wheel_options'] = ', '.join(wheel_options) + + legrest_options = kw.getlist('legrest_options') if hasattr(kw, 'getlist') else [] + if not legrest_options and 'legrest_options' in kw: + legrest_options = [kw.get('legrest_options')] if kw.get('legrest_options') else [] + if legrest_options: + vals['legrest_options'] = ', '.join(legrest_options) + + additional_adp_options = kw.getlist('additional_adp_options') if hasattr(kw, 'getlist') else [] + if not additional_adp_options and 'additional_adp_options' in kw: + additional_adp_options = [kw.get('additional_adp_options')] if kw.get('additional_adp_options') else [] + if additional_adp_options: + vals['additional_adp_options'] = ', '.join(additional_adp_options) + + # Powerchair options + powerchair_options = kw.getlist('powerchair_options') if hasattr(kw, 'getlist') else [] + if not powerchair_options and 'powerchair_options' in kw: + powerchair_options = [kw.get('powerchair_options')] if kw.get('powerchair_options') else [] + if powerchair_options: + vals['powerchair_options'] = ', '.join(powerchair_options) + + specialty_controls = kw.getlist('specialty_controls') if hasattr(kw, 'getlist') else [] + if not specialty_controls and 'specialty_controls' in kw: + specialty_controls = [kw.get('specialty_controls')] if kw.get('specialty_controls') else [] + if specialty_controls: + vals['specialty_controls'] = ', '.join(specialty_controls) + + # Seatbelt type + if kw.get('seatbelt_type'): + vals['seatbelt_type'] = kw.get('seatbelt_type') + + # Additional customization + if kw.get('additional_customization'): + vals['additional_customization'] = kw.get('additional_customization') + + # Cushion and backrest + if kw.get('cushion_info'): + vals['cushion_info'] = kw.get('cushion_info') + if kw.get('backrest_info'): + vals['backrest_info'] = kw.get('backrest_info') + + # Client type + if kw.get('client_type'): + vals['client_type'] = kw.get('client_type') + + # Client info (Page 2) + if kw.get('client_first_name'): + vals['client_first_name'] = kw.get('client_first_name') + if kw.get('client_middle_name'): + vals['client_middle_name'] = kw.get('client_middle_name') + if kw.get('client_last_name'): + vals['client_last_name'] = kw.get('client_last_name') + + # Build full client name + name_parts = [] + if kw.get('client_first_name'): + name_parts.append(kw.get('client_first_name')) + if kw.get('client_middle_name'): + name_parts.append(kw.get('client_middle_name')) + if kw.get('client_last_name'): + name_parts.append(kw.get('client_last_name')) + if name_parts: + vals['client_name'] = ' '.join(name_parts) + + # Health card + if kw.get('client_health_card'): + vals['client_health_card'] = kw.get('client_health_card') + if kw.get('client_health_card_version'): + vals['client_health_card_version'] = kw.get('client_health_card_version') + + # Address + if kw.get('client_street'): + vals['client_street'] = kw.get('client_street') + if kw.get('client_unit'): + vals['client_unit'] = kw.get('client_unit') + if kw.get('client_city'): + vals['client_city'] = kw.get('client_city') + if kw.get('client_state'): + vals['client_state'] = kw.get('client_state') + if kw.get('client_postal_code'): + vals['client_postal_code'] = kw.get('client_postal_code') + if kw.get('client_country_id'): + try: + vals['client_country_id'] = int(kw.get('client_country_id')) + except (ValueError, TypeError): + pass + + # Contact + if kw.get('client_phone'): + vals['client_phone'] = kw.get('client_phone') + if kw.get('client_email'): + vals['client_email'] = kw.get('client_email') + + # Dates + date_fields = ['assessment_start_date', 'assessment_end_date', 'claim_authorization_date', 'previous_funding_date'] + for field in date_fields: + if kw.get(field): + try: + vals[field] = kw.get(field) + except (ValueError, TypeError): + pass + + # Reason for application + if kw.get('reason_for_application'): + vals['reason_for_application'] = kw.get('reason_for_application') + + # Authorizer + if kw.get('authorizer_id'): + try: + vals['authorizer_id'] = int(kw.get('authorizer_id')) + except (ValueError, TypeError): + pass + + # Existing partner selection + if kw.get('partner_id'): + try: + partner_id = int(kw.get('partner_id')) + if partner_id > 0: + vals['partner_id'] = partner_id + vals['create_new_partner'] = False + else: + vals['create_new_partner'] = True + except (ValueError, TypeError): + vals['create_new_partner'] = True + + # ===== PAGE 11: Consent & Declaration fields ===== + if kw.get('consent_signed_by'): + vals['consent_signed_by'] = kw.get('consent_signed_by') + + if kw.get('consent_declaration_accepted'): + vals['consent_declaration_accepted'] = True + + if kw.get('consent_date'): + try: + vals['consent_date'] = kw.get('consent_date') + except (ValueError, TypeError): + pass + + # Agent fields (only relevant when consent_signed_by == 'agent') + agent_fields = [ + 'agent_relationship', 'agent_first_name', 'agent_last_name', + 'agent_middle_initial', 'agent_unit', 'agent_street_number', + 'agent_street_name', 'agent_city', 'agent_province', + 'agent_postal_code', 'agent_home_phone', 'agent_business_phone', + 'agent_phone_ext', + ] + for field in agent_fields: + if kw.get(field): + vals[field] = kw.get(field) + + return vals + + def _get_canadian_provinces(self): + """Return list of Canadian provinces for dropdown""" + return [ + ('Ontario', 'Ontario'), + ('Quebec', 'Quebec'), + ('British Columbia', 'British Columbia'), + ('Alberta', 'Alberta'), + ('Manitoba', 'Manitoba'), + ('Saskatchewan', 'Saskatchewan'), + ('Nova Scotia', 'Nova Scotia'), + ('New Brunswick', 'New Brunswick'), + ('Newfoundland and Labrador', 'Newfoundland and Labrador'), + ('Prince Edward Island', 'Prince Edward Island'), + ('Northwest Territories', 'Northwest Territories'), + ('Yukon', 'Yukon'), + ('Nunavut', 'Nunavut'), + ] + + # ========================================================================= + # LOANER PORTAL ROUTES + # ========================================================================= + + @http.route('/my/loaner/categories', type='jsonrpc', auth='user', website=True) + def portal_loaner_categories(self, **kw): + """Return loaner product categories.""" + parent = request.env.ref('fusion_claims.product_category_loaner', raise_if_not_found=False) + if not parent: + return [] + categories = request.env['product.category'].sudo().search([ + ('parent_id', '=', parent.id), + ], order='name') + return [{'id': c.id, 'name': c.name} for c in categories] + + @http.route('/my/loaner/products', type='jsonrpc', auth='user', website=True) + def portal_loaner_products(self, **kw): + """Return available loaner products and their serial numbers.""" + domain = [('x_fc_can_be_loaned', '=', True)] + category_id = kw.get('category_id') + if category_id: + domain.append(('categ_id', '=', int(category_id))) + + products = request.env['product.product'].sudo().search(domain) + loaner_location = request.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False) + + result = [] + for p in products: + lots = [] + if loaner_location: + quants = request.env['stock.quant'].sudo().search([ + ('product_id', '=', p.id), + ('location_id', '=', loaner_location.id), + ('quantity', '>', 0), + ]) + for q in quants: + if q.lot_id: + lots.append({'id': q.lot_id.id, 'name': q.lot_id.name}) + result.append({ + 'id': p.id, + 'name': p.name, + 'category_id': p.categ_id.id, + 'period_days': p.product_tmpl_id.x_fc_loaner_period_days or 7, + 'lots': lots, + }) + return result + + @http.route('/my/loaner/locations', type='jsonrpc', auth='user', website=True) + def portal_loaner_locations(self, **kw): + """Return internal stock locations for return.""" + locations = request.env['stock.location'].sudo().search([ + ('usage', '=', 'internal'), + ('company_id', '=', request.env.company.id), + ]) + return [{'id': loc.id, 'name': loc.complete_name} for loc in locations] + + @http.route('/my/loaner/checkout', type='jsonrpc', auth='user', website=True) + def portal_loaner_checkout(self, **kw): + """Checkout a loaner from the portal.""" + partner = request.env.user.partner_id + if not partner.is_sales_rep_portal and not partner.is_authorizer: + return {'error': 'Unauthorized'} + + product_id = int(kw.get('product_id', 0)) + lot_id = int(kw.get('lot_id', 0)) if kw.get('lot_id') else False + sale_order_id = int(kw.get('sale_order_id', 0)) if kw.get('sale_order_id') else False + client_id = int(kw.get('client_id', 0)) if kw.get('client_id') else False + loaner_period = int(kw.get('loaner_period_days', 7)) + condition = kw.get('checkout_condition', 'good') + notes = kw.get('checkout_notes', '') + + if not product_id: + return {'error': 'Product is required'} + + vals = { + 'product_id': product_id, + 'loaner_period_days': loaner_period, + 'checkout_condition': condition, + 'checkout_notes': notes, + 'sales_rep_id': request.env.user.id, + } + if lot_id: + vals['lot_id'] = lot_id + if sale_order_id: + so = request.env['sale.order'].sudo().browse(sale_order_id) + if so.exists(): + vals['sale_order_id'] = so.id + vals['partner_id'] = so.partner_id.id + vals['authorizer_id'] = so.x_fc_authorizer_id.id if so.x_fc_authorizer_id else False + vals['delivery_address'] = so.partner_shipping_id.contact_address if so.partner_shipping_id else '' + if client_id and not vals.get('partner_id'): + vals['partner_id'] = client_id + + if not vals.get('partner_id'): + return {'error': 'Client is required'} + + try: + checkout = request.env['fusion.loaner.checkout'].sudo().create(vals) + checkout.action_checkout() + return { + 'success': True, + 'checkout_id': checkout.id, + 'name': checkout.name, + 'message': f'Loaner {checkout.name} checked out successfully', + } + except Exception as e: + _logger.error(f"Loaner checkout error: {e}") + return {'error': str(e)} + + @http.route('/my/loaner/create-product', type='jsonrpc', auth='user', website=True) + def portal_loaner_create_product(self, **kw): + """Quick-create a loaner product with serial number from the portal.""" + partner = request.env.user.partner_id + if not partner.is_sales_rep_portal and not partner.is_authorizer: + return {'error': 'Unauthorized'} + + product_name = kw.get('product_name', '').strip() + serial_number = kw.get('serial_number', '').strip() + + if not product_name: + return {'error': 'Product name is required'} + if not serial_number: + return {'error': 'Serial number is required'} + + try: + # Use provided category or default to Loaner Equipment + category_id = kw.get('category_id') + if category_id: + category = request.env['product.category'].sudo().browse(int(category_id)) + if not category.exists(): + category = None + else: + category = None + + if not category: + category = request.env.ref('fusion_claims.product_category_loaner', raise_if_not_found=False) + if not category: + category = request.env['product.category'].sudo().search([ + ('name', '=', 'Loaner Equipment'), + ], limit=1) + if not category: + category = request.env['product.category'].sudo().create({ + 'name': 'Loaner Equipment', + }) + + # Create product template + product_tmpl = request.env['product.template'].sudo().create({ + 'name': product_name, + 'type': 'consu', + 'tracking': 'serial', + 'categ_id': category.id, + 'x_fc_can_be_loaned': True, + 'x_fc_loaner_period_days': 7, + 'sale_ok': False, + 'purchase_ok': False, + }) + product = product_tmpl.product_variant_id + + # Create serial number (lot) + lot = request.env['stock.lot'].sudo().create({ + 'name': serial_number, + 'product_id': product.id, + 'company_id': request.env.company.id, + }) + + # Add stock in loaner location + loaner_location = request.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False) + if loaner_location: + request.env['stock.quant'].sudo().create({ + 'product_id': product.id, + 'location_id': loaner_location.id, + 'lot_id': lot.id, + 'quantity': 1, + }) + + return { + 'success': True, + 'product_id': product.id, + 'product_name': product.name, + 'lot_id': lot.id, + 'lot_name': lot.name, + } + except Exception as e: + _logger.error(f"Loaner product creation error: {e}") + return {'error': str(e)} + + @http.route('/my/loaner/return', type='jsonrpc', auth='user', website=True) + def portal_loaner_return(self, **kw): + """Return/pickup a loaner from the portal.""" + partner = request.env.user.partner_id + if not partner.is_sales_rep_portal and not partner.is_authorizer: + return {'error': 'Unauthorized'} + + checkout_id = int(kw.get('checkout_id', 0)) + return_condition = kw.get('return_condition', 'good') + return_notes = kw.get('return_notes', '') + return_location_id = int(kw.get('return_location_id', 0)) if kw.get('return_location_id') else None + + if not checkout_id: + return {'error': 'Checkout ID is required'} + + try: + checkout = request.env['fusion.loaner.checkout'].sudo().browse(checkout_id) + if not checkout.exists(): + return {'error': 'Checkout not found'} + if checkout.state not in ('checked_out', 'overdue', 'rental_pending'): + return {'error': 'This loaner is not currently checked out'} + + checkout.action_process_return( + return_condition=return_condition, + return_notes=return_notes, + return_location_id=return_location_id, + ) + return { + 'success': True, + 'message': f'Loaner {checkout.name} returned successfully', + } + except Exception as e: + _logger.error(f"Loaner return error: {e}") + return {'error': str(e)} + + # ========================================================================== + # PUBLIC ASSESSMENT BOOKING + # ========================================================================== + + @http.route('/book-assessment', type='http', auth='public', website=True, sitemap=True) + def portal_book_assessment(self, **kw): + """Public page for booking an accessibility assessment.""" + # Get available sales reps for assignment + SalesGroup = request.env.ref('sales_team.group_sale_salesman', raise_if_not_found=False) + sales_reps = [] + if SalesGroup: + sales_reps = request.env['res.users'].sudo().search([ + ('groups_id', 'in', [SalesGroup.id]), + ('active', '=', True), + ]) + + assessment_types = [ + ('stairlift_straight', 'Straight Stair Lift'), + ('stairlift_curved', 'Curved Stair Lift'), + ('vpl', 'Vertical Platform Lift'), + ('ceiling_lift', 'Ceiling Lift'), + ('ramp', 'Custom Ramp'), + ('bathroom', 'Bathroom Modification'), + ('tub_cutout', 'Tub Cutout'), + ] + + values = { + 'assessment_types': assessment_types, + 'sales_reps': sales_reps, + 'success': kw.get('success'), + 'error': kw.get('error'), + } + return request.render('fusion_authorizer_portal.portal_book_assessment', values) + + @http.route('/book-assessment/submit', type='http', auth='public', website=True, methods=['POST'], csrf=True) + def portal_book_assessment_submit(self, **kw): + """Process assessment booking form submission.""" + try: + # Validate required fields + if not kw.get('client_name') or not kw.get('client_phone'): + return request.redirect('/book-assessment?error=Please+provide+client+name+and+phone+number') + + if not kw.get('assessment_type'): + return request.redirect('/book-assessment?error=Please+select+an+assessment+type') + + Assessment = request.env['fusion.accessibility.assessment'].sudo() + + # Determine booking source + booking_source = 'portal' + if kw.get('booking_source'): + booking_source = kw['booking_source'] + + # Parse date + assessment_date = False + if kw.get('assessment_date'): + try: + assessment_date = fields.Date.from_string(kw['assessment_date']) + except Exception: + assessment_date = False + + # Determine sales rep + sales_rep_id = False + if kw.get('sales_rep_id'): + try: + sales_rep_id = int(kw['sales_rep_id']) + except (ValueError, TypeError): + pass + + # Build address string + address_parts = [] + if kw.get('client_street'): + address_parts.append(kw['client_street']) + if kw.get('client_city'): + address_parts.append(kw['client_city']) + if kw.get('client_province'): + address_parts.append(kw['client_province']) + if kw.get('client_postal'): + address_parts.append(kw['client_postal']) + + vals = { + 'assessment_type': kw['assessment_type'], + 'client_name': kw['client_name'], + 'client_phone': kw.get('client_phone', ''), + 'client_email': kw.get('client_email', ''), + 'client_address': ', '.join(address_parts) if address_parts else '', + 'client_address_street': kw.get('client_street', ''), + 'client_address_city': kw.get('client_city', ''), + 'client_address_province': kw.get('client_province', ''), + 'client_address_postal': kw.get('client_postal', ''), + 'assessment_date': assessment_date, + 'booking_source': booking_source, + 'modification_requested': kw.get('modification_requested', ''), + } + + if sales_rep_id: + vals['sales_rep_id'] = sales_rep_id + + # Link authorizer if provided + if kw.get('authorizer_name') and kw.get('authorizer_email'): + Partner = request.env['res.partner'].sudo() + authorizer = Partner.search([('email', '=', kw['authorizer_email'])], limit=1) + if not authorizer: + authorizer = Partner.create({ + 'name': kw['authorizer_name'], + 'email': kw['authorizer_email'], + 'phone': kw.get('authorizer_phone', ''), + 'is_authorizer': True, + }) + vals['authorizer_id'] = authorizer.id + + assessment = Assessment.create(vals) + + # Create calendar event for the sales rep + if assessment_date and sales_rep_id: + try: + from datetime import datetime as dt, timedelta + # Default: 10 AM, 1.5 hour duration + start = dt.combine(assessment_date, dt.min.time().replace(hour=10)) + stop = start + timedelta(hours=1, minutes=30) + event = request.env['calendar.event'].sudo().create({ + 'name': f'Assessment: {kw["client_name"]} ({kw.get("client_city", "")})', + 'start': fields.Datetime.to_string(start), + 'stop': fields.Datetime.to_string(stop), + 'user_id': sales_rep_id, + 'location': vals.get('client_address', ''), + 'description': ( + f'Accessibility Assessment Booking\n' + f'Client: {kw["client_name"]}\n' + f'Phone: {kw.get("client_phone", "")}\n' + f'Type: {kw["assessment_type"]}\n' + f'Request: {kw.get("modification_requested", "")}' + ), + 'partner_ids': [(4, request.env['res.users'].sudo().browse(sales_rep_id).partner_id.id)], + }) + assessment.write({'calendar_event_id': event.id}) + except Exception as e: + _logger.error(f"Failed to create calendar event: {e}") + + # Send authorizer notification email + if assessment.authorizer_id and assessment.authorizer_id.email: + try: + company = request.env.company + body_html = assessment._email_build( + title='Assessment Scheduled', + summary=f'An accessibility assessment has been booked for ' + f'{kw["client_name"]}.', + email_type='info', + sections=[('Booking Details', [ + ('Client', kw['client_name']), + ('Phone', kw.get('client_phone', '')), + ('Address', vals.get('client_address', '')), + ('Assessment Type', dict(Assessment._fields['assessment_type'].selection).get(kw['assessment_type'], '')), + ('Date', str(assessment_date) if assessment_date else 'TBD'), + ('Requested', kw.get('modification_requested', '')), + ])], + note='This booking was made through the online portal.', + sender_name=company.name, + ) + # Replace footer + body_html = body_html.replace( + 'This is an automated notification from the ADP Claims Management System.', + 'This is an automated notification from the Accessibility Case Management System.', + ) + request.env['mail.mail'].sudo().create({ + 'subject': f'Assessment Booked - {kw["client_name"]}', + 'body_html': body_html, + 'email_to': assessment.authorizer_id.email, + 'model': 'fusion.accessibility.assessment', + 'res_id': assessment.id, + }).send() + except Exception as e: + _logger.error(f"Failed to send authorizer notification: {e}") + + # Send Twilio SMS to client + if kw.get('client_phone'): + try: + ICP = request.env['ir.config_parameter'].sudo() + if ICP.get_param('fusion_claims.twilio_enabled', 'False').lower() in ('true', '1', 'yes'): + import requests as req + account_sid = ICP.get_param('fusion_claims.twilio_account_sid', '') + auth_token = ICP.get_param('fusion_claims.twilio_auth_token', '') + from_number = ICP.get_param('fusion_claims.twilio_phone_number', '') + company_phone = request.env.company.phone or '' + date_str = str(assessment_date) if assessment_date else 'a date to be confirmed' + sms_body = ( + f"Hi {kw['client_name']}, your accessibility assessment with " + f"Westin Healthcare has been booked for {date_str}. " + f"For questions, call {company_phone}." + ) + if all([account_sid, auth_token, from_number]): + url = f'https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json' + req.post(url, data={ + 'To': kw['client_phone'], + 'From': from_number, + 'Body': sms_body, + }, auth=(account_sid, auth_token), timeout=10) + assessment.write({'sms_confirmation_sent': True}) + except Exception as e: + _logger.error(f"Failed to send SMS: {e}") + + return request.redirect('/book-assessment?success=1') + + except Exception as e: + _logger.error(f"Assessment booking error: {e}") + return request.redirect(f'/book-assessment?error={str(e)}') diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/controllers/portal_main.py b/fusion_authorizer_portal/fusion_authorizer_portal/controllers/portal_main.py new file mode 100644 index 0000000..e05288a --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/controllers/portal_main.py @@ -0,0 +1,2468 @@ +# -*- coding: utf-8 -*- + +from odoo import http, _, fields +from odoo.http import request +from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager +from odoo.exceptions import AccessError, MissingError +import base64 +import logging +import pytz + +_logger = logging.getLogger(__name__) + + +class AuthorizerPortal(CustomerPortal): + """Portal controller for Authorizers (OTs/Therapists)""" + + @http.route(['/my', '/my/home'], type='http', auth='user', website=True) + def home(self, **kw): + """Override home to add ADP posting info for Fusion users""" + partner = request.env.user.partner_id + + # Get the standard portal home response + response = super().home(**kw) + + # Add ADP posting info and other data for Fusion users + 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) + + # Add signature count (documents to sign) - only if Sign module is installed + sign_count = 0 + sign_module_available = 'sign.request.item' in request.env + if sign_module_available: + sign_count = request.env['sign.request.item'].sudo().search_count([ + ('partner_id', '=', partner.id), + ('state', '=', 'sent'), + ]) + response.qcontext['sign_count'] = sign_count + response.qcontext['sign_module_available'] = sign_module_available + + return response + + def _prepare_home_portal_values(self, counters): + """Add authorizer/sales rep counts to portal home""" + values = super()._prepare_home_portal_values(counters) + partner = request.env.user.partner_id + + if 'authorizer_case_count' in counters: + if partner.is_authorizer: + values['authorizer_case_count'] = request.env['sale.order'].sudo().search_count([ + ('x_fc_authorizer_id', '=', partner.id) + ]) + else: + values['authorizer_case_count'] = 0 + + if 'sales_rep_case_count' in counters: + if partner.is_sales_rep_portal: + values['sales_rep_case_count'] = request.env['sale.order'].sudo().search_count([ + ('user_id', '=', request.env.user.id) + ]) + else: + values['sales_rep_case_count'] = 0 + + if 'assessment_count' in counters: + count = 0 + if partner.is_authorizer: + count += request.env['fusion.assessment'].sudo().search_count([ + ('authorizer_id', '=', partner.id) + ]) + if partner.is_sales_rep_portal: + count += request.env['fusion.assessment'].sudo().search_count([ + ('sales_rep_id', '=', request.env.user.id) + ]) + values['assessment_count'] = count + + if 'technician_delivery_count' in counters: + if partner.is_technician_portal: + values['technician_delivery_count'] = request.env['sale.order'].sudo().search_count([ + ('x_fc_delivery_technician_ids', 'in', [request.env.user.id]) + ]) + else: + values['technician_delivery_count'] = 0 + + # Add ADP posting schedule info for portal users + if partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal: + values.update(self._get_adp_posting_info()) + + return values + + def _get_adp_posting_info(self): + """Get ADP posting schedule information for the portal home.""" + from datetime import date, timedelta + + ICP = request.env['ir.config_parameter'].sudo() + + # Get base date and frequency from settings + base_date_str = ICP.get_param('fusion_claims.adp_posting_base_date', '2026-01-23') + frequency = int(ICP.get_param('fusion_claims.adp_posting_frequency_days', '14')) + + try: + base_date = date.fromisoformat(base_date_str) + except (ValueError, TypeError): + base_date = date(2026, 1, 23) + + # Get user's timezone for accurate date display + user_tz = request.env.user.tz or 'UTC' + try: + tz = pytz.timezone(user_tz) + except pytz.exceptions.UnknownTimeZoneError: + tz = pytz.UTC + + # Get today's date in user's timezone + from datetime import datetime + now_utc = datetime.now(pytz.UTC) + now_local = now_utc.astimezone(tz) + today = now_local.date() + + # Calculate next posting date + if today < base_date: + next_posting = base_date + else: + days_since_base = (today - base_date).days + cycles_passed = days_since_base // frequency + next_posting = base_date + timedelta(days=(cycles_passed + 1) * frequency) + + # If today is a posting day, return the next one + if days_since_base % frequency == 0: + next_posting = base_date + timedelta(days=(cycles_passed + 1) * frequency) + + # Calculate key dates for the posting cycle + # Wednesday is submission deadline (posting day - 2 if posting is Friday) + days_until_wednesday = (next_posting.weekday() - 2) % 7 + if days_until_wednesday == 0 and next_posting.weekday() != 2: + days_until_wednesday = 7 + submission_deadline = next_posting - timedelta(days=days_until_wednesday) + + # Get next 3 posting dates for the calendar + posting_dates = [] + current_posting = next_posting + for i in range(6): + posting_dates.append({ + 'date': current_posting.isoformat(), + 'display': current_posting.strftime('%B %d, %Y'), + 'day': current_posting.day, + 'month': current_posting.strftime('%B'), + 'year': current_posting.year, + 'weekday': current_posting.strftime('%A'), + 'is_next': i == 0, + }) + current_posting = current_posting + timedelta(days=frequency) + + # Days until next posting + days_until_posting = (next_posting - today).days + + return { + 'next_posting_date': next_posting, + 'next_posting_display': next_posting.strftime('%B %d, %Y'), + 'next_posting_weekday': next_posting.strftime('%A'), + 'submission_deadline': submission_deadline, + 'submission_deadline_display': submission_deadline.strftime('%B %d, %Y'), + 'days_until_posting': days_until_posting, + 'posting_dates': posting_dates, + 'current_month': today.strftime('%B %Y'), + 'today': today, + } + + # ==================== AUTHORIZER PORTAL ==================== + + @http.route(['/my/authorizer', '/my/authorizer/dashboard'], type='http', auth='user', website=True) + def authorizer_dashboard(self, **kw): + """Authorizer dashboard - simplified mobile-first view""" + partner = request.env.user.partner_id + + if not partner.is_authorizer: + return request.redirect('/my') + + SaleOrder = request.env['sale.order'].sudo() + Assessment = request.env['fusion.assessment'].sudo() + + # Base domain for this authorizer + base_domain = [('x_fc_authorizer_id', '=', partner.id)] + + # Total cases + total_cases = SaleOrder.search_count(base_domain) + + # Assessment counts (express + accessibility) + express_count = Assessment.search_count([('authorizer_id', '=', partner.id)]) + accessibility_count = 0 + if 'fusion.accessibility.assessment' in request.env: + accessibility_count = request.env['fusion.accessibility.assessment'].sudo().search_count([ + ('authorizer_id', '=', partner.id) + ]) + assessment_count = express_count + accessibility_count + + # Cases needing authorizer attention (waiting for application) + needs_attention = SaleOrder.search( + base_domain + [('x_fc_adp_application_status', 'in', [ + 'waiting_for_application', 'assessment_completed', + ])], + order='write_date desc', + limit=10, + ) + + # Human-readable status labels + status_labels = {} + if needs_attention: + status_labels = dict(needs_attention[0]._fields['x_fc_adp_application_status'].selection) + + # Sale type labels + sale_type_labels = {} + if total_cases: + sample = SaleOrder.search(base_domain, limit=1) + if sample and 'x_fc_sale_type' in sample._fields: + sale_type_labels = dict(sample._fields['x_fc_sale_type'].selection) + + # Recent cases (last 5 updated) + recent_cases = SaleOrder.search( + base_domain, + order='write_date desc', + limit=5, + ) + + # Get status labels from recent cases if not already loaded + if not status_labels and recent_cases: + status_labels = dict(recent_cases[0]._fields['x_fc_adp_application_status'].selection) + + # Pending assessments + pending_assessments = Assessment.search([ + ('authorizer_id', '=', partner.id), + ('state', 'in', ['draft', 'pending_signature']) + ], limit=5, order='assessment_date desc') + + company = request.env.company + + values = { + 'partner': partner, + 'company': company, + 'total_cases': total_cases, + 'assessment_count': assessment_count, + 'needs_attention': needs_attention, + 'recent_cases': recent_cases, + 'pending_assessments': pending_assessments, + 'status_labels': status_labels, + 'sale_type_labels': sale_type_labels, + 'page_name': 'authorizer_dashboard', + } + + return request.render('fusion_authorizer_portal.portal_authorizer_dashboard', values) + + @http.route(['/my/authorizer/cases', '/my/authorizer/cases/page/'], type='http', auth='user', website=True) + def authorizer_cases(self, page=1, search='', sortby='date', sale_type='', **kw): + """List of cases assigned to the authorizer""" + partner = request.env.user.partner_id + + if not partner.is_authorizer: + return request.redirect('/my') + + SaleOrder = request.env['sale.order'].sudo() + + # Sale type groupings for filtering + sale_type_groups = { + 'adp': ['adp', 'adp_odsp'], + 'odsp': ['odsp'], + 'march_of_dimes': ['march_of_dimes'], + 'others': ['wsib', 'direct_private', 'insurance', 'muscular_dystrophy', 'other', 'rental'], + } + + # Build domain + from odoo.osv import expression + domain = [('x_fc_authorizer_id', '=', partner.id)] + + # Add sale type filter + if sale_type and sale_type in sale_type_groups: + domain.append(('x_fc_sale_type', 'in', sale_type_groups[sale_type])) + + # Add search filter + if search: + search_domain = [ + '|', '|', '|', '|', + ('partner_id.name', 'ilike', search), + ('name', 'ilike', search), + ('x_fc_claim_number', 'ilike', search), + ('x_fc_client_ref_1', 'ilike', search), + ('x_fc_client_ref_2', 'ilike', search), + ] + domain = expression.AND([domain, search_domain]) + + # Sorting + sortings = { + 'date': {'label': _('Date'), 'order': 'date_order desc'}, + 'name': {'label': _('Reference'), 'order': 'name'}, + 'client': {'label': _('Client'), 'order': 'partner_id'}, + 'state': {'label': _('Status'), 'order': 'state'}, + } + order = sortings.get(sortby, sortings['date'])['order'] + + # Pager + case_count = SaleOrder.search_count(domain) + pager = portal_pager( + url='/my/authorizer/cases', + url_args={'search': search, 'sortby': sortby, 'sale_type': sale_type}, + total=case_count, + page=page, + step=20, + ) + + # Get cases + cases = SaleOrder.search(domain, order=order, limit=20, offset=pager['offset']) + + values = { + 'cases': cases, + 'pager': pager, + 'search': search, + 'sortby': sortby, + 'sortings': sortings, + 'sale_type': sale_type, + 'sale_type_label': { + 'adp': 'ADP Cases', + 'odsp': 'ODSP Cases', + 'march_of_dimes': 'March of Dimes', + 'others': 'Other Cases', + }.get(sale_type, 'All Cases'), + 'page_name': 'authorizer_cases', + } + + return request.render('fusion_authorizer_portal.portal_authorizer_cases', values) + + @http.route('/my/authorizer/cases/search', type='jsonrpc', auth='user') + def authorizer_cases_search(self, query='', **kw): + """AJAX search endpoint for real-time search""" + partner = request.env.user.partner_id + + if not partner.is_authorizer: + return {'error': 'Access denied', 'results': []} + + if len(query) < 2: + return {'results': []} + + SaleOrder = request.env['sale.order'].sudo() + orders = SaleOrder.get_authorizer_portal_cases(partner.id, search_query=query, limit=50) + + results = [] + for order in orders: + results.append({ + 'id': order.id, + 'name': order.name, + 'partner_name': order.partner_id.name if order.partner_id else '', + 'date_order': order.date_order.strftime('%Y-%m-%d') if order.date_order else '', + 'state': order.state, + 'state_display': dict(order._fields['state'].selection).get(order.state, order.state), + 'claim_number': getattr(order, 'x_fc_claim_number', '') or '', + 'client_ref_1': order.x_fc_client_ref_1 or '', + 'client_ref_2': order.x_fc_client_ref_2 or '', + 'url': f'/my/authorizer/case/{order.id}', + }) + + return {'results': results} + + @http.route('/my/authorizer/case/', type='http', auth='user', website=True) + def authorizer_case_detail(self, order_id, **kw): + """View a specific case""" + partner = request.env.user.partner_id + + if not partner.is_authorizer: + return request.redirect('/my') + + try: + order = request.env['sale.order'].sudo().browse(order_id) + if not order.exists() or order.x_fc_authorizer_id.id != partner.id: + raise AccessError(_('You do not have access to this case.')) + except (AccessError, MissingError): + return request.redirect('/my/authorizer/cases') + + # Get documents + documents = request.env['fusion.adp.document'].sudo().search([ + ('sale_order_id', '=', order_id), + ('is_current', '=', True), + ]) + + # Get messages from chatter - only those relevant to this user + # (authored by them, sent to them, or mentioning them) + all_messages = request.env['mail.message'].sudo().search([ + ('model', '=', 'sale.order'), + ('res_id', '=', order_id), + ('message_type', 'in', ['comment', 'notification']), + ('body', '!=', ''), + ('body', '!=', '


'), + ], order='date desc', limit=100) + + # Filter to only show messages relevant to this partner: + # 1. Messages authored by this partner + # 2. Messages where this partner is in notified_partner_ids + # 3. Messages where this partner is mentioned (partner_ids) + def is_relevant_message(msg): + if not msg.body or len(msg.body.strip()) == 0 or '


' in msg.body: + return False + # Authored by current partner + if msg.author_id.id == partner.id: + return True + # Partner is in notified partners + if partner.id in msg.notified_partner_ids.ids: + return True + # Partner is mentioned in partner_ids + if partner.id in msg.partner_ids.ids: + return True + return False + + filtered_messages = all_messages.filtered(is_relevant_message) + + values = { + 'order': order, + 'documents': documents, + 'messages': filtered_messages, + 'page_name': 'authorizer_case_detail', + } + + return request.render('fusion_authorizer_portal.portal_authorizer_case_detail', values) + + @http.route('/my/authorizer/case//comment', type='http', auth='user', website=True, methods=['POST']) + def authorizer_add_comment(self, order_id, comment='', **kw): + """Add a comment to a case - posts to sale order chatter and emails salesperson""" + partner = request.env.user.partner_id + + if not partner.is_authorizer: + return request.redirect('/my') + + if not comment.strip(): + return request.redirect(f'/my/authorizer/case/{order_id}') + + try: + order = request.env['sale.order'].sudo().browse(order_id) + if not order.exists() or order.x_fc_authorizer_id.id != partner.id: + raise AccessError(_('You do not have access to this case.')) + + # Post message to sale order chatter (internal note, not to all followers) + message = order.message_post( + body=comment.strip(), + message_type='comment', + subtype_xmlid='mail.mt_note', # Internal note - doesn't notify followers + author_id=partner.id, + ) + + # Send email notification to the salesperson + if order.user_id and order.user_id.partner_id: + from markupsafe import Markup + salesperson_partner = order.user_id.partner_id + order.message_notify( + partner_ids=[salesperson_partner.id], + body=Markup(f"

New message from Authorizer {partner.name}:

{comment.strip()}

"), + subject=f"[{order.name}] New message from Authorizer", + author_id=partner.id, + ) + + # Also save to fusion.authorizer.comment for portal display + if 'fusion.authorizer.comment' in request.env: + request.env['fusion.authorizer.comment'].sudo().create({ + 'sale_order_id': order_id, + 'author_id': partner.id, + 'comment': comment.strip(), + 'comment_type': 'general', + }) + + except Exception as e: + _logger.error(f"Error adding comment: {e}") + + return request.redirect(f'/my/authorizer/case/{order_id}') + + @http.route('/my/authorizer/case//upload', type='http', auth='user', website=True, methods=['POST'], csrf=True) + def authorizer_upload_document(self, order_id, document_type='full_application', document_file=None, revision_note='', **kw): + """Upload a document for a case""" + partner = request.env.user.partner_id + + if not partner.is_authorizer: + return request.redirect('/my') + + if not document_file or not document_file.filename: + return request.redirect(f'/my/authorizer/case/{order_id}') + + try: + order = request.env['sale.order'].sudo().browse(order_id) + if not order.exists() or order.x_fc_authorizer_id.id != partner.id: + raise AccessError(_('You do not have access to this case.')) + + # Don't allow authorizers to upload 'submitted_final' + if document_type == 'submitted_final': + document_type = 'full_application' + + file_content = document_file.read() + file_base64 = base64.b64encode(file_content) + + request.env['fusion.adp.document'].sudo().create({ + 'sale_order_id': order_id, + 'document_type': document_type, + 'file': file_base64, + 'filename': document_file.filename, + 'revision_note': revision_note, + 'source': 'authorizer', + }) + + except Exception as e: + _logger.error(f"Error uploading document: {e}") + + return request.redirect(f'/my/authorizer/case/{order_id}') + + @http.route('/my/authorizer/document//download', type='http', auth='user') + def authorizer_download_document(self, doc_id, **kw): + """Download a document""" + partner = request.env.user.partner_id + + if not partner.is_authorizer and not partner.is_sales_rep_portal: + return request.redirect('/my') + + try: + document = request.env['fusion.adp.document'].sudo().browse(doc_id) + if not document.exists(): + raise MissingError(_('Document not found.')) + + # Verify access + if document.sale_order_id: + order = document.sale_order_id + has_access = ( + (partner.is_authorizer and order.x_fc_authorizer_id.id == partner.id) or + (partner.is_sales_rep_portal and order.user_id.id == request.env.user.id) + ) + if not has_access: + raise AccessError(_('You do not have access to this document.')) + + file_content = base64.b64decode(document.file) + + # Check if viewing inline or downloading + view_inline = kw.get('view', '0') == '1' + disposition = 'inline' if view_inline else 'attachment' + + return request.make_response( + file_content, + headers=[ + ('Content-Type', document.mimetype or 'application/octet-stream'), + ('Content-Disposition', f'{disposition}; filename="{document.filename}"'), + ('Content-Length', len(file_content)), + ] + ) + + except Exception as e: + _logger.error(f"Error downloading document: {e}") + return request.redirect('/my') + + @http.route(['/my/authorizer/case//attachment/', + '/my/sales/case//attachment/'], type='http', auth='user') + def authorizer_download_attachment(self, order_id, attachment_type, **kw): + """Download an attachment from sale order (original application, xml, proof of delivery)""" + partner = request.env.user.partner_id + + if not partner.is_authorizer and not partner.is_sales_rep_portal: + return request.redirect('/my') + + try: + order = request.env['sale.order'].sudo().browse(order_id) + if not order.exists(): + raise MissingError(_('Order not found.')) + + # Verify access + has_access = ( + (partner.is_authorizer and order.x_fc_authorizer_id.id == partner.id) or + (partner.is_sales_rep_portal and order.user_id.id == request.env.user.id) + ) + if not has_access: + raise AccessError(_('You do not have access to this order.')) + + # Get the attachment based on type + attachment_map = { + 'original_application': ('x_fc_original_application', 'x_fc_original_application_filename', 'application/pdf', 'original_application.pdf'), + 'final_application': ('x_fc_final_submitted_application', 'x_fc_final_application_filename', 'application/pdf', 'final_application.pdf'), + 'xml_file': ('x_fc_xml_file', 'x_fc_xml_filename', 'application/xml', 'application.xml'), + 'proof_of_delivery': ('x_fc_proof_of_delivery', 'x_fc_proof_of_delivery_filename', 'application/pdf', 'proof_of_delivery.pdf'), + } + + if attachment_type not in attachment_map: + raise MissingError(_('Invalid attachment type.')) + + field_name, filename_field, default_mimetype, default_filename = attachment_map[attachment_type] + + if not hasattr(order, field_name) or not getattr(order, field_name): + raise MissingError(_('Attachment not found.')) + + file_content = base64.b64decode(getattr(order, field_name)) + filename = getattr(order, filename_field, None) or default_filename + + # Check if viewing inline or downloading + view_inline = kw.get('view', '0') == '1' + disposition = 'inline' if view_inline else 'attachment' + + return request.make_response( + file_content, + headers=[ + ('Content-Type', default_mimetype), + ('Content-Disposition', f'{disposition}; filename="{filename}"'), + ('Content-Length', len(file_content)), + ] + ) + + except Exception as e: + _logger.error(f"Error downloading attachment: {e}") + return request.redirect('/my') + + @http.route(['/my/authorizer/case//photo/', + '/my/sales/case//photo/'], type='http', auth='user') + def authorizer_view_photo(self, order_id, photo_id, **kw): + """View an approval photo""" + partner = request.env.user.partner_id + + if not partner.is_authorizer and not partner.is_sales_rep_portal: + return request.redirect('/my') + + try: + order = request.env['sale.order'].sudo().browse(order_id) + if not order.exists(): + raise MissingError(_('Order not found.')) + + # Verify access + has_access = ( + (partner.is_authorizer and order.x_fc_authorizer_id.id == partner.id) or + (partner.is_sales_rep_portal and order.user_id.id == request.env.user.id) + ) + if not has_access: + raise AccessError(_('You do not have access to this order.')) + + # Find the photo attachment + attachment = request.env['ir.attachment'].sudo().browse(photo_id) + if not attachment.exists() or attachment.id not in order.x_fc_approval_photo_ids.ids: + raise MissingError(_('Photo not found.')) + + file_content = base64.b64decode(attachment.datas) + + return request.make_response( + file_content, + headers=[ + ('Content-Type', attachment.mimetype or 'image/png'), + ('Content-Disposition', f'inline; filename="{attachment.name}"'), + ('Content-Length', len(file_content)), + ] + ) + + except Exception as e: + _logger.error(f"Error viewing photo: {e}") + return request.redirect('/my') + + # ==================== SALES REP PORTAL ==================== + + @http.route(['/my/sales', '/my/sales/dashboard'], type='http', auth='user', website=True) + def sales_rep_dashboard(self, search='', sale_type='', status='', **kw): + """Sales rep dashboard with search and filters""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_sales_rep_portal: + return request.redirect('/my') + + SaleOrder = request.env['sale.order'].sudo() + Assessment = request.env['fusion.assessment'].sudo() + + # Get case counts by status (unfiltered for stats) + base_domain = [('user_id', '=', user.id)] + + draft_count = SaleOrder.search_count(base_domain + [('state', '=', 'draft')]) + sent_count = SaleOrder.search_count(base_domain + [('state', '=', 'sent')]) + sale_count = SaleOrder.search_count(base_domain + [('state', '=', 'sale')]) + total_count = SaleOrder.search_count(base_domain) + + # Build filtered domain for recent cases + filtered_domain = base_domain.copy() + + # Apply search filter + if search: + search = search.strip() + filtered_domain += [ + '|', '|', '|', + ('name', 'ilike', search), + ('partner_id.name', 'ilike', search), + ('x_fc_claim_number', 'ilike', search), + ('partner_id.email', 'ilike', search), + ] + + # Apply sale type filter + if sale_type: + filtered_domain += [('x_fc_sale_type', '=', sale_type)] + + # Apply status filter + if status: + filtered_domain += [('state', '=', status)] + + # Recent cases (filtered) + recent_cases = SaleOrder.search(filtered_domain, limit=20, order='date_order desc') + + # Assessments + assessment_domain = [('sales_rep_id', '=', user.id)] + pending_assessments = Assessment.search( + assessment_domain + [('state', 'in', ['draft', 'pending_signature'])], + limit=5, order='assessment_date desc' + ) + completed_assessments_count = Assessment.search_count( + assessment_domain + [('state', '=', 'completed')] + ) + + values = { + 'partner': partner, + 'draft_count': draft_count, + 'sent_count': sent_count, + 'sale_count': sale_count, + 'total_count': total_count, + 'recent_cases': recent_cases, + 'pending_assessments': pending_assessments, + 'completed_assessments_count': completed_assessments_count, + 'page_name': 'sales_dashboard', + # Search and filter values + 'search': search, + 'sale_type_filter': sale_type, + 'status_filter': status, + } + + return request.render('fusion_authorizer_portal.portal_sales_dashboard', values) + + @http.route(['/my/sales/cases', '/my/sales/cases/page/'], type='http', auth='user', website=True) + def sales_rep_cases(self, page=1, search='', sortby='date', **kw): + """List of cases for the sales rep""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_sales_rep_portal: + return request.redirect('/my') + + SaleOrder = request.env['sale.order'].sudo() + + # Build domain + from odoo.osv import expression + domain = [('user_id', '=', user.id)] + + # Add search filter + if search: + search_domain = [ + '|', '|', '|', '|', + ('partner_id.name', 'ilike', search), + ('name', 'ilike', search), + ('x_fc_claim_number', 'ilike', search), + ('x_fc_client_ref_1', 'ilike', search), + ('x_fc_client_ref_2', 'ilike', search), + ] + domain = expression.AND([domain, search_domain]) + + # Sorting + sortings = { + 'date': {'label': _('Date'), 'order': 'date_order desc'}, + 'name': {'label': _('Reference'), 'order': 'name'}, + 'client': {'label': _('Client'), 'order': 'partner_id'}, + 'state': {'label': _('Status'), 'order': 'state'}, + } + order = sortings.get(sortby, sortings['date'])['order'] + + # Pager + case_count = SaleOrder.search_count(domain) + pager = portal_pager( + url='/my/sales/cases', + url_args={'search': search, 'sortby': sortby}, + total=case_count, + page=page, + step=20, + ) + + # Get cases + cases = SaleOrder.search(domain, order=order, limit=20, offset=pager['offset']) + + values = { + 'cases': cases, + 'pager': pager, + 'search': search, + 'sortby': sortby, + 'sortings': sortings, + 'page_name': 'sales_cases', + } + + return request.render('fusion_authorizer_portal.portal_sales_cases', values) + + @http.route('/my/sales/cases/search', type='jsonrpc', auth='user') + def sales_rep_cases_search(self, query='', **kw): + """AJAX search endpoint for sales rep real-time search""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_sales_rep_portal: + return {'error': 'Access denied', 'results': []} + + if len(query) < 2: + return {'results': []} + + SaleOrder = request.env['sale.order'].sudo() + orders = SaleOrder.get_sales_rep_portal_cases(user.id, search_query=query, limit=50) + + results = [] + for order in orders: + results.append({ + 'id': order.id, + 'name': order.name, + 'partner_name': order.partner_id.name if order.partner_id else '', + 'date_order': order.date_order.strftime('%Y-%m-%d') if order.date_order else '', + 'state': order.state, + 'state_display': dict(order._fields['state'].selection).get(order.state, order.state), + 'claim_number': getattr(order, 'x_fc_claim_number', '') or '', + 'client_ref_1': order.x_fc_client_ref_1 or '', + 'client_ref_2': order.x_fc_client_ref_2 or '', + 'url': f'/my/sales/case/{order.id}', + }) + + return {'results': results} + + @http.route('/my/sales/case/', type='http', auth='user', website=True) + def sales_rep_case_detail(self, order_id, **kw): + """View a specific case for sales rep""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_sales_rep_portal: + return request.redirect('/my') + + try: + order = request.env['sale.order'].sudo().browse(order_id) + if not order.exists() or order.user_id.id != user.id: + raise AccessError(_('You do not have access to this case.')) + except (AccessError, MissingError): + return request.redirect('/my/sales/cases') + + # Get documents + documents = request.env['fusion.adp.document'].sudo().search([ + ('sale_order_id', '=', order_id), + ('is_current', '=', True), + ]) + + # Get messages from chatter - only those relevant to this user + all_messages = request.env['mail.message'].sudo().search([ + ('model', '=', 'sale.order'), + ('res_id', '=', order_id), + ('message_type', 'in', ['comment', 'notification']), + ('body', '!=', ''), + ('body', '!=', '


'), + ], order='date desc', limit=100) + + # Filter to only show messages relevant to this partner: + # 1. Messages authored by this partner + # 2. Messages where this partner is in notified_partner_ids + # 3. Messages where this partner is mentioned (partner_ids) + def is_relevant_message(msg): + if not msg.body or len(msg.body.strip()) == 0 or '


' in msg.body: + return False + # Authored by current partner + if msg.author_id.id == partner.id: + return True + # Partner is in notified partners + if partner.id in msg.notified_partner_ids.ids: + return True + # Partner is mentioned in partner_ids + if partner.id in msg.partner_ids.ids: + return True + return False + + filtered_messages = all_messages.filtered(is_relevant_message) + + values = { + 'order': order, + 'documents': documents, + 'messages': filtered_messages, + 'page_name': 'sales_case_detail', + } + + return request.render('fusion_authorizer_portal.portal_sales_case_detail', values) + + @http.route('/my/sales/case//comment', type='http', auth='user', website=True, methods=['POST']) + def sales_rep_add_comment(self, order_id, comment='', **kw): + """Add a comment to a case (sales rep) - posts to sale order chatter and emails authorizer""" + partner = request.env.user.partner_id + user = request.env.user + + if not partner.is_sales_rep_portal: + return request.redirect('/my') + + if not comment.strip(): + return request.redirect(f'/my/sales/case/{order_id}') + + try: + order = request.env['sale.order'].sudo().browse(order_id) + if not order.exists() or order.user_id.id != user.id: + raise AccessError(_('You do not have access to this case.')) + + # Post message to sale order chatter (internal note, not to all followers) + message = order.message_post( + body=comment.strip(), + message_type='comment', + subtype_xmlid='mail.mt_note', # Internal note - doesn't notify followers + author_id=partner.id, + ) + + # Send email notification to the authorizer + if order.x_fc_authorizer_id: + from markupsafe import Markup + order.message_notify( + partner_ids=[order.x_fc_authorizer_id.id], + body=Markup(f"

New message from Sales Rep {partner.name}:

{comment.strip()}

"), + subject=f"[{order.name}] New message from Sales Rep", + author_id=partner.id, + ) + + # Also save to fusion.authorizer.comment for portal display + if 'fusion.authorizer.comment' in request.env: + request.env['fusion.authorizer.comment'].sudo().create({ + 'sale_order_id': order_id, + 'author_id': partner.id, + 'comment': comment.strip(), + 'comment_type': 'general', + }) + + except Exception as e: + _logger.error(f"Error adding comment: {e}") + + return request.redirect(f'/my/sales/case/{order_id}') + + # ==================== CLIENT FUNDING CLAIMS PORTAL ==================== + + def _prepare_home_portal_values(self, counters): + """Add client funding claims count to portal home""" + values = super()._prepare_home_portal_values(counters) + partner = request.env.user.partner_id + + if 'funding_claims_count' in counters: + # Count sale orders where partner is the customer + values['funding_claims_count'] = request.env['sale.order'].sudo().search_count([ + ('partner_id', '=', partner.id), + ('x_fc_sale_type', 'in', ['adp', 'adp_odsp', 'odsp', 'march_of_dimes']), + ]) + + return values + + @http.route(['/my/funding-claims', '/my/funding-claims/page/'], type='http', auth='user', website=True) + def client_funding_claims(self, page=1, sortby='date', **kw): + """List of funding claims for the client""" + partner = request.env.user.partner_id + SaleOrder = request.env['sale.order'].sudo() + + # Build domain - orders where partner is the customer + domain = [ + ('partner_id', '=', partner.id), + ('x_fc_sale_type', 'in', ['adp', 'adp_odsp', 'odsp', 'march_of_dimes']), + ] + + # Sorting + sortings = { + 'date': {'label': _('Date'), 'order': 'date_order desc'}, + 'name': {'label': _('Reference'), 'order': 'name'}, + 'status': {'label': _('Status'), 'order': 'x_fc_adp_application_status'}, + } + order = sortings.get(sortby, sortings['date'])['order'] + + # Pager + claim_count = SaleOrder.search_count(domain) + pager = portal_pager( + url='/my/funding-claims', + url_args={'sortby': sortby}, + total=claim_count, + page=page, + step=20, + ) + + # Get claims + claims = SaleOrder.search(domain, order=order, limit=20, offset=pager['offset']) + + values = { + 'claims': claims, + 'pager': pager, + 'sortby': sortby, + 'sortings': sortings, + 'page_name': 'funding_claims', + } + + return request.render('fusion_authorizer_portal.portal_client_claims', values) + + @http.route('/my/funding-claims/', type='http', auth='user', website=True) + def client_funding_claim_detail(self, order_id, **kw): + """View a specific funding claim""" + partner = request.env.user.partner_id + + try: + order = request.env['sale.order'].sudo().browse(order_id) + if not order.exists() or order.partner_id.id != partner.id: + raise AccessError(_('You do not have access to this claim.')) + except (AccessError, MissingError): + return request.redirect('/my/funding-claims') + + # Check if case is closed - documents only visible after case closed + is_case_closed = order.x_fc_adp_application_status == 'case_closed' + + # Get documents (only if case is closed) + documents = [] + if is_case_closed: + documents = request.env['fusion.adp.document'].sudo().search([ + ('sale_order_id', '=', order_id), + ('is_current', '=', True), + ('document_type', 'in', ['submitted_final', 'pages_11_12']), + ]) + + # Get invoices + invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel') + + values = { + 'order': order, + 'is_case_closed': is_case_closed, + 'documents': documents, + 'invoices': invoices, + 'page_name': 'funding_claim_detail', + } + + return request.render('fusion_authorizer_portal.portal_client_claim_detail', values) + + @http.route('/my/funding-claims//document//download', type='http', auth='user') + def client_download_document(self, order_id, doc_id, **kw): + """Download a document from a funding claim""" + partner = request.env.user.partner_id + + try: + order = request.env['sale.order'].sudo().browse(order_id) + if not order.exists() or order.partner_id.id != partner.id: + raise AccessError(_('You do not have access to this claim.')) + + # Check if case is closed + if order.x_fc_adp_application_status != 'case_closed': + raise AccessError(_('Documents are only available after the case is closed.')) + + document = request.env['fusion.adp.document'].sudo().browse(doc_id) + if not document.exists() or document.sale_order_id.id != order_id: + raise MissingError(_('Document not found.')) + + file_content = base64.b64decode(document.file) + + return request.make_response( + file_content, + headers=[ + ('Content-Type', document.mimetype or 'application/octet-stream'), + ('Content-Disposition', f'attachment; filename="{document.filename}"'), + ('Content-Length', len(file_content)), + ] + ) + + except Exception as e: + _logger.error(f"Error downloading document: {e}") + return request.redirect('/my/funding-claims') + + @http.route('/my/funding-claims//proof-of-delivery', type='http', auth='user') + def client_download_proof_of_delivery(self, order_id, **kw): + """Download proof of delivery from a funding claim""" + partner = request.env.user.partner_id + + try: + order = request.env['sale.order'].sudo().browse(order_id) + if not order.exists() or order.partner_id.id != partner.id: + raise AccessError(_('You do not have access to this claim.')) + + # Check if case is closed + if order.x_fc_adp_application_status != 'case_closed': + raise AccessError(_('Documents are only available after the case is closed.')) + + if not order.x_fc_proof_of_delivery: + raise MissingError(_('Proof of delivery not found.')) + + file_content = base64.b64decode(order.x_fc_proof_of_delivery) + filename = order.x_fc_proof_of_delivery_filename or 'proof_of_delivery.pdf' + + return request.make_response( + file_content, + headers=[ + ('Content-Type', 'application/pdf'), + ('Content-Disposition', f'attachment; filename="{filename}"'), + ('Content-Length', len(file_content)), + ] + ) + + except Exception as e: + _logger.error(f"Error downloading proof of delivery: {e}") + return request.redirect('/my/funding-claims') + + # ==================== 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 + return True + + @http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True) + def technician_dashboard(self, **kw): + """Technician dashboard - today's schedule with timeline.""" + if not self._check_technician_access(): + return request.redirect('/my') + + partner = request.env.user.partner_id + user = request.env.user + Task = request.env['fusion.technician.task'].sudo() + SaleOrder = request.env['sale.order'].sudo() + today = fields.Date.context_today(request.env['fusion.technician.task']) + + # Today's tasks + today_tasks = Task.search([ + ('technician_id', '=', user.id), + ('scheduled_date', '=', today), + ('status', '!=', 'cancelled'), + ], order='sequence, time_start, id') + + # Current in-progress task + current_task = today_tasks.filtered(lambda t: t.status == 'in_progress')[:1] + + # Next upcoming task (first scheduled/en_route today) + next_task = today_tasks.filtered(lambda t: t.status in ('scheduled', 'en_route'))[:1] + + # Stats + completed_today = len(today_tasks.filtered(lambda t: t.status == 'completed')) + remaining_today = len(today_tasks.filtered(lambda t: t.status in ('scheduled', 'en_route', 'in_progress'))) + total_today = len(today_tasks) + + # Total travel time for the day + total_travel = sum(today_tasks.mapped('travel_time_minutes')) + + # Legacy: deliveries assigned (for backward compat with existing delivery views) + delivery_domain = [('x_fc_delivery_technician_ids', 'in', [user.id])] + pending_pod_count = SaleOrder.search_count(delivery_domain + [ + ('x_fc_pod_signature', '=', False), + ('x_fc_adp_application_status', '=', 'ready_delivery'), + ]) + + # Tomorrow's task count + from datetime import timedelta + tomorrow = today + timedelta(days=1) + tomorrow_count = Task.search_count([ + ('technician_id', '=', user.id), + ('scheduled_date', '=', tomorrow), + ('status', '!=', 'cancelled'), + ]) + + # Technician's personal start address + start_address = user.sudo().x_fc_start_address or '' + + # Google Maps API key for Places autocomplete + ICP = request.env['ir.config_parameter'].sudo() + google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '') + + values = { + 'today_tasks': today_tasks, + 'current_task': current_task, + 'next_task': next_task, + 'completed_today': completed_today, + 'remaining_today': remaining_today, + 'total_today': total_today, + 'total_travel': total_travel, + 'pending_pod_count': pending_pod_count, + 'tomorrow_count': tomorrow_count, + 'today_date': today, + 'start_address': start_address, + 'google_maps_api_key': google_maps_api_key, + 'page_name': 'technician_dashboard', + } + return request.render('fusion_authorizer_portal.portal_technician_dashboard', values) + + @http.route(['/my/technician/tasks', '/my/technician/tasks/page/'], type='http', auth='user', website=True) + def technician_tasks(self, page=1, search='', filter_status='all', filter_date='', **kw): + """List of all tasks for the technician.""" + if not self._check_technician_access(): + return request.redirect('/my') + + user = request.env.user + Task = request.env['fusion.technician.task'].sudo() + + domain = [('technician_id', '=', user.id)] + + if filter_status == 'scheduled': + domain.append(('status', '=', 'scheduled')) + elif filter_status == 'in_progress': + domain.append(('status', 'in', ('en_route', 'in_progress'))) + elif filter_status == 'completed': + domain.append(('status', '=', 'completed')) + elif filter_status == 'active': + domain.append(('status', 'not in', ('cancelled', 'completed'))) + # Default: show all + + if filter_date: + domain.append(('scheduled_date', '=', filter_date)) + + if search: + domain += ['|', '|', '|', + ('name', 'ilike', search), + ('partner_id.name', 'ilike', search), + ('address_city', 'ilike', search), + ('sale_order_id.name', 'ilike', search), + ] + + task_count = Task.search_count(domain) + pager = portal_pager( + url='/my/technician/tasks', + url_args={'search': search, 'filter_status': filter_status, 'filter_date': filter_date}, + total=task_count, + page=page, + step=20, + ) + + tasks = Task.search(domain, limit=20, offset=pager['offset'], + order='scheduled_date desc, sequence, time_start') + + values = { + 'tasks': tasks, + 'pager': pager, + 'search': search, + 'filter_status': filter_status, + 'filter_date': filter_date, + 'page_name': 'technician_tasks', + } + return request.render('fusion_authorizer_portal.portal_technician_tasks', values) + + @http.route('/my/technician/task/', type='http', auth='user', website=True) + def technician_task_detail(self, task_id, **kw): + """View a specific technician task.""" + 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: + 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), + ('scheduled_date', '=', task.scheduled_date), + ('time_start', '<', task.time_start), + ('status', 'not in', ['completed', 'cancelled']), + ('id', '!=', task.id), + ], order='time_start', limit=1) + + # Get order lines if linked to a sale order + order_lines = [] + if task.sale_order_id: + order_lines = task.sale_order_id.order_line.filtered(lambda l: not l.display_type) + + # Get VAPID public key for push notifications + vapid_public = request.env['ir.config_parameter'].sudo().get_param( + 'fusion_claims.vapid_public_key', '' + ) + + values = { + 'task': task, + 'order_lines': order_lines, + 'vapid_public_key': vapid_public, + 'page_name': 'technician_task_detail', + 'earlier_incomplete': earlier_incomplete, + } + return request.render('fusion_authorizer_portal.portal_technician_task_detail', values) + + @http.route('/my/technician/task//add-notes', type='json', auth='user', website=True) + def technician_task_add_notes(self, task_id, notes, photos=None, **kw): + """Add notes (and optional photos) to a completed task. + + :param notes: text content of the note + :param photos: list of dicts with 'data' (base64 data-url) and 'name' + """ + if not self._check_technician_access(): + return {'success': False, 'error': 'Access denied'} + user = request.env.user + Task = request.env['fusion.technician.task'].sudo() + Attachment = request.env['ir.attachment'].sudo() + try: + task = Task.browse(task_id) + if not task.exists() or task.technician_id.id != user.id: + return {'success': False, 'error': 'Task not found'} + + from markupsafe import Markup, escape + import re + + # ---------------------------------------------------------- + # Process photos -> create ir.attachment records + # ---------------------------------------------------------- + attachment_ids = [] + if photos: + for i, photo in enumerate(photos): + photo_data = photo.get('data', '') + photo_name = photo.get('name', f'photo_{i+1}.jpg') + if not photo_data: + continue + # Strip data-url prefix (e.g. "data:image/jpeg;base64,...") + if ',' in photo_data: + photo_data = photo_data.split(',', 1)[1] + try: + att = Attachment.create({ + 'name': photo_name, + 'type': 'binary', + 'datas': photo_data, + 'res_model': 'fusion.technician.task', + 'res_id': task.id, + 'mimetype': 'image/jpeg', + }) + attachment_ids.append(att.id) + except Exception as e: + _logger.warning("Failed to attach photo %s: %s", photo_name, e) + + # ---------------------------------------------------------- + # Sanitize and format the notes text + # ---------------------------------------------------------- + safe_notes = str(escape(notes or '')) + formatted_notes = re.sub(r'\n', '
', safe_notes) + + timestamp = fields.Datetime.now().strftime("%b %d, %Y %I:%M %p") + safe_user = str(escape(user.name)) + safe_task = str(escape(task.name)) + + has_text = bool((notes or '').strip()) + photo_count = len(attachment_ids) + + # Build a small photo summary for inline display + photo_html = '' + if photo_count: + photo_html = '
%d photo(s) attached
' % photo_count + + # --- 1. Append to the completion_notes field on the task --- + note_parts = [] + if has_text: + note_parts.append( + '
%s
' % formatted_notes + ) + if photo_html: + note_parts.append(photo_html) + + if note_parts: + new_note = Markup( + '
' + '%s - %s' + '%s' + '
' + ) % (Markup(timestamp), Markup(safe_user), Markup(''.join(note_parts))) + + existing = task.completion_notes or '' + task.completion_notes = Markup(existing) + new_note + + # --- 2. Post to the TASK chatter --- + chatter_parts = [] + if has_text: + chatter_parts.append( + '
%s
' % formatted_notes + ) + if photo_html: + chatter_parts.append(photo_html) + + task_chatter = Markup( + '
' + ' Note Added' + '%s' + 'By %s' + '
' + ) % (Markup(''.join(chatter_parts)), Markup(safe_user)) + + task.message_post( + body=task_chatter, + message_type='comment', + subtype_xmlid='mail.mt_note', + attachment_ids=attachment_ids, + ) + + # --- 3. Post to the SALE ORDER chatter (if linked) --- + if task.sale_order_id: + so_chatter = Markup( + '
' + ' Technician Note - %s' + '%s' + 'By %s on %s' + '
' + ) % (Markup(safe_task), Markup(''.join(chatter_parts)), Markup(safe_user), Markup(timestamp)) + + # Duplicate attachments for the sale order so both records show them + so_att_ids = [] + for att_id in attachment_ids: + att = Attachment.browse(att_id) + so_att = att.copy({ + 'res_model': 'sale.order', + 'res_id': task.sale_order_id.id, + }) + so_att_ids.append(so_att.id) + + task.sale_order_id.message_post( + body=so_chatter, + message_type='comment', + subtype_xmlid='mail.mt_note', + attachment_ids=so_att_ids, + ) + + return {'success': True} + except Exception as e: + _logger.error(f"Add notes error: {e}") + return {'success': False, 'error': str(e)} + + @http.route('/my/technician/task//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).""" + 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: + return {'success': False, 'error': 'Task not found or not assigned to you'} + + if action == 'en_route': + task.action_start_en_route() + elif action == 'start': + task.action_start_task() + elif action == 'complete': + completion_notes = kw.get('completion_notes', '') + if completion_notes: + task.completion_notes = completion_notes + task.action_complete_task() + elif action == 'cancel': + task.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, + 'redirect_url': f'/my/technician/task/{task_id}', + } + if action == 'complete': + next_task = task.get_next_task_for_technician() + if next_task: + result['next_task_id'] = next_task.id + result['next_task_url'] = f'/my/technician/task/{next_task.id}' + result['next_task_name'] = next_task.name + result['next_task_time'] = task._float_to_time_str(next_task.time_start) + else: + result['next_task_id'] = False + result['all_done'] = True + return result + except Exception as e: + _logger.error(f"Task action error: {e}") + return {'success': False, 'error': str(e)} + + @http.route('/my/technician/task//voice-transcribe', type='json', auth='user', website=True) + def technician_voice_transcribe(self, task_id, audio_data, mime_type='audio/webm', **kw): + """Transcribe voice recording using OpenAI Whisper, translate to English.""" + if not self._check_technician_access(): + return {'success': False, 'error': 'Access denied'} + + 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: + 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', '') + if not api_key: + return {'success': False, 'error': 'OpenAI API key not configured'} + + import base64 + import tempfile + import os + import requests as http_requests + + try: + # Decode audio + audio_bytes = base64.b64decode(audio_data) + ext_map = {'audio/webm': '.webm', 'audio/ogg': '.ogg', 'audio/mp4': '.m4a', 'audio/wav': '.wav'} + ext = ext_map.get(mime_type, '.webm') + + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: + tmp.write(audio_bytes) + tmp_path = tmp.name + + # Call Whisper API - use 'translations' endpoint to auto-translate to English + with open(tmp_path, 'rb') as f: + resp = http_requests.post( + 'https://api.openai.com/v1/audio/translations', + headers={'Authorization': f'Bearer {api_key}'}, + files={'file': (f'recording{ext}', f, mime_type)}, + data={'model': 'whisper-1', 'response_format': 'text'}, + timeout=60, + ) + + os.unlink(tmp_path) + + if resp.status_code != 200: + return {'success': False, 'error': f'Whisper API error: {resp.text}'} + + transcription = resp.text.strip() + + # Store transcription and audio on task + task.write({ + 'voice_note_audio': audio_data, + 'voice_note_transcription': transcription, + }) + + return {'success': True, 'transcription': transcription} + + except Exception as e: + _logger.error(f"Voice transcription error: {e}") + return {'success': False, 'error': str(e)} + + @http.route('/my/technician/task//ai-format', type='json', auth='user', website=True) + def technician_ai_format(self, task_id, text, **kw): + """Use GPT to clean up and format raw notes text.""" + if not self._check_technician_access(): + return {'success': False, 'error': 'Access denied'} + + 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: + 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', '') + if not api_key: + return {'success': False, 'error': 'OpenAI API key not configured'} + + ai_model = ICP.get_param('fusion_claims.ai_model', 'gpt-4o-mini') + + import requests as http_requests + try: + task_type_label = dict(Task._fields['task_type'].selection).get(task.task_type, task.task_type) + system_prompt = ( + "You are formatting a field technician's notes into clear, professional text. " + "ALWAYS output in English. If the input is in another language, translate it to English. " + "Fix grammar, spelling, and punctuation. Remove filler words. " + "Keep all facts from the original. Make it concise and professional. " + "If it mentions work done, parts used, issues, or follow-ups, organize them clearly. " + "Return plain text only (no HTML)." + ) + + resp = http_requests.post( + 'https://api.openai.com/v1/chat/completions', + headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'}, + json={ + 'model': ai_model, + 'messages': [ + {'role': 'system', 'content': system_prompt}, + {'role': 'user', 'content': f'Task type: {task_type_label}\nClient: {task.partner_id.name or "N/A"}\nRaw notes:\n{text}'}, + ], + 'temperature': 0.3, + 'max_tokens': 1000, + }, + timeout=30, + ) + + if resp.status_code == 200: + data = resp.json() + formatted = data['choices'][0]['message']['content'] + return {'success': True, 'formatted_text': formatted} + else: + return {'success': False, 'error': f'AI service error ({resp.status_code})'} + except Exception as e: + _logger.error(f"AI format error: {e}") + return {'success': False, 'error': str(e)} + + @http.route('/my/technician/task//voice-complete', type='json', auth='user', website=True) + def technician_voice_complete(self, task_id, transcription, **kw): + """Format transcription with GPT and complete the task.""" + if not self._check_technician_access(): + return {'success': False, 'error': 'Access denied'} + + 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: + 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', '') + ai_model = ICP.get_param('fusion_claims.ai_model', 'gpt-4o-mini') + + formatted_notes = transcription # fallback + + if api_key: + import requests as http_requests + try: + task_type_label = dict(Task._fields['task_type'].selection).get(task.task_type, task.task_type) + system_prompt = ( + "You are formatting a technician's voice note into a structured completion report. " + "ALWAYS output in English. If the input is in another language, translate it to English. " + "The technician recorded this after completing a field task. " + "Format it into clear, professional HTML with these sections:\n" + "Work Performed: [summary]\n" + "Parts Used: [if mentioned, otherwise 'None mentioned']\n" + "Issues Found: [if any, otherwise 'None']\n" + "Follow-up Required: [yes/no + details]\n" + "Client Feedback: [if mentioned, otherwise 'N/A']\n\n" + "Keep all facts from the original. Fix grammar. Remove filler words. " + "Use
for line breaks, for labels. Keep it concise." + ) + + resp = http_requests.post( + 'https://api.openai.com/v1/chat/completions', + headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'}, + json={ + 'model': ai_model, + 'messages': [ + {'role': 'system', 'content': system_prompt}, + {'role': 'user', 'content': f'Task type: {task_type_label}\nTechnician: {user.name}\nVoice note:\n{transcription}'}, + ], + 'temperature': 0.3, + 'max_tokens': 1000, + }, + timeout=30, + ) + + if resp.status_code == 200: + data = resp.json() + formatted_notes = data['choices'][0]['message']['content'] + except Exception as e: + _logger.warning(f"GPT formatting failed, using raw transcription: {e}") + + # Build final HTML completion report + from markupsafe import Markup + completion_html = Markup( + f'
' + f'

Technician: {user.name}
' + f'Task: {task.name} ({dict(Task._fields["task_type"].selection).get(task.task_type, task.task_type)})

' + f'
' + f'{formatted_notes}' + f'
' + ) + + task.write({ + 'completion_notes': completion_html, + 'voice_note_transcription': transcription, + }) + task.action_complete_task() + + return { + 'success': True, + 'formatted_notes': formatted_notes, + 'redirect_url': f'/my/technician/task/{task_id}', + } + + @http.route('/my/technician/tomorrow', type='http', auth='user', website=True) + def technician_tomorrow(self, **kw): + """Next day preparation view.""" + if not self._check_technician_access(): + return request.redirect('/my') + + user = request.env.user + Task = request.env['fusion.technician.task'].sudo() + from datetime import timedelta + today = fields.Date.context_today(Task) + tomorrow = today + timedelta(days=1) + + tomorrow_tasks = Task.search([ + ('technician_id', '=', user.id), + ('scheduled_date', '=', tomorrow), + ('status', '!=', 'cancelled'), + ], order='sequence, time_start, id') + + total_travel = sum(tomorrow_tasks.mapped('travel_time_minutes')) + total_distance = sum(tomorrow_tasks.mapped('travel_distance_km')) + + # Aggregate equipment needed + all_equipment = [] + for t in tomorrow_tasks: + if t.equipment_needed: + all_equipment.append(f"{t.name}: {t.equipment_needed}") + + values = { + 'tomorrow_tasks': tomorrow_tasks, + 'tomorrow_date': tomorrow, + 'total_travel': total_travel, + 'total_distance': total_distance, + 'all_equipment': all_equipment, + 'page_name': 'technician_tomorrow', + } + return request.render('fusion_authorizer_portal.portal_technician_tomorrow', values) + + @http.route('/my/technician/schedule/', type='http', auth='user', website=True) + def technician_schedule_date(self, date, **kw): + """View schedule for a specific date.""" + if not self._check_technician_access(): + return request.redirect('/my') + + user = request.env.user + Task = request.env['fusion.technician.task'].sudo() + + try: + schedule_date = fields.Date.from_string(date) + except (ValueError, TypeError): + return request.redirect('/my/technician') + + tasks = Task.search([ + ('technician_id', '=', user.id), + ('scheduled_date', '=', schedule_date), + ('status', '!=', 'cancelled'), + ], order='sequence, time_start, id') + + total_travel = sum(tasks.mapped('travel_time_minutes')) + + values = { + 'tasks': tasks, + 'schedule_date': schedule_date, + 'total_travel': total_travel, + 'page_name': 'technician_schedule', + } + return request.render('fusion_authorizer_portal.portal_technician_schedule_date', values) + + @http.route('/my/technician/admin/map', type='http', auth='user', website=True) + def technician_location_map(self, **kw): + """Admin map view showing latest technician locations using Google Maps.""" + user = request.env.user + if not user.has_group('sales_team.group_sale_manager') and not user.has_group('sales_team.group_sale_salesman'): + return request.redirect('/my') + + LocationModel = request.env['fusion.technician.location'].sudo() + locations = LocationModel.get_latest_locations() + api_key = request.env['ir.config_parameter'].sudo().get_param( + 'fusion_claims.google_maps_api_key', '' + ) + values = { + 'locations': locations, + 'google_maps_api_key': api_key, + } + return request.render('fusion_authorizer_portal.portal_technician_map', values) + + @http.route('/my/technician/location/log', type='json', auth='user', website=True) + def technician_location_log(self, latitude, longitude, accuracy=None, **kw): + """Log the technician's current GPS location.""" + if not self._check_technician_access(): + return {'success': False} + try: + request.env['fusion.technician.location'].sudo().log_location( + latitude=latitude, + longitude=longitude, + accuracy=accuracy, + ) + return {'success': True} + except Exception as e: + _logger.warning(f"Location log error: {e}") + return {'success': 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.""" + if not self._check_technician_access(): + return {'success': False, 'error': 'Access denied'} + try: + request.env.user.sudo().write({ + 'x_fc_start_address': address.strip() if address else False, + }) + return {'success': True} + except Exception as e: + _logger.warning("Error saving start location: %s", e) + return {'success': False, 'error': str(e)} + + @http.route('/my/technician/push/subscribe', type='json', auth='user', website=True) + def technician_push_subscribe(self, endpoint, p256dh, auth, **kw): + """Register a push notification subscription.""" + user = request.env.user + PushSub = request.env['fusion.push.subscription'].sudo() + browser_info = request.httprequest.headers.get('User-Agent', '')[:200] + sub = PushSub.register_subscription(user.id, endpoint, p256dh, auth, browser_info) + return {'success': True, 'subscription_id': sub.id} + + # Keep legacy delivery routes for backward compatibility + @http.route(['/my/technician/deliveries', '/my/technician/deliveries/page/'], type='http', auth='user', website=True) + def technician_deliveries(self, page=1, search='', filter_status='all', **kw): + """Legacy: List of deliveries for the technician (redirects to tasks).""" + return request.redirect('/my/technician/tasks?filter_status=all') + + @http.route('/my/technician/delivery/', type='http', auth='user', website=True) + def technician_delivery_detail(self, order_id, **kw): + """View a specific delivery for technician (legacy, still works).""" + if not self._check_technician_access(): + return request.redirect('/my') + + user = request.env.user + try: + order = request.env['sale.order'].sudo().browse(order_id) + if not order.exists() or user.id not in order.x_fc_delivery_technician_ids.ids: + raise AccessError(_('You do not have access to this delivery.')) + except (AccessError, MissingError): + return request.redirect('/my/technician/tasks') + + values = { + 'order': order, + 'page_name': 'technician_delivery_detail', + } + return request.render('fusion_authorizer_portal.portal_technician_delivery_detail_legacy', values) + + # ==================== POD SIGNATURE CAPTURE ==================== + + @http.route('/my/pod/', type='http', auth='user', website=True) + def pod_signature_page(self, order_id, **kw): + """POD signature capture page - accessible by technicians and sales reps""" + partner = request.env.user.partner_id + user = request.env.user + + try: + order = request.env['sale.order'].sudo().browse(order_id) + if not order.exists(): + raise MissingError(_('Order not found.')) + + # Check access - technician (via delivery list or task assignment), sales rep, or internal staff + has_access = False + user_role = None + + # Technician: check delivery technician list on the order + if partner.is_technician_portal and user.id in order.x_fc_delivery_technician_ids.ids: + has_access = True + user_role = 'technician' + + # Technician: also check if user is assigned to a task linked to this order + 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), + ]) + if task_count: + has_access = True + user_role = 'technician' + + # Internal users with field staff flag can always collect POD + if not has_access and not user.share: + has_access = True + user_role = 'technician' + + # Sales rep: own orders + if not has_access: + if partner.is_sales_rep_portal and order.user_id.id == user.id: + has_access = True + user_role = 'sales_rep' + elif order.user_id.id == user.id: + has_access = True + user_role = 'sales_rep' + + if not has_access: + raise AccessError(_('You do not have access to this order.')) + + except (AccessError, MissingError) as e: + _logger.warning(f"POD access denied for user {user.id} on order {order_id}: {e}") + return request.redirect('/my') + + # Get delivery address + delivery_address = order.partner_shipping_id or order.partner_id + + values = { + 'order': order, + 'delivery_address': delivery_address, + 'user_role': user_role, + 'has_existing_signature': bool(order.x_fc_pod_signature), + 'page_name': 'pod_signature', + } + + return request.render('fusion_authorizer_portal.portal_pod_signature', values) + + @http.route('/my/pod//sign', type='json', auth='user', methods=['POST']) + def pod_save_signature(self, order_id, client_name, signature_data, signature_date=None, **kw): + """Save POD signature via AJAX""" + partner = request.env.user.partner_id + user = request.env.user + + try: + order = request.env['sale.order'].sudo().browse(order_id) + if not order.exists(): + return {'success': False, 'error': 'Order not found'} + + # Check access - same logic as pod_signature_page + has_access = False + if partner.is_technician_portal and user.id in order.x_fc_delivery_technician_ids.ids: + has_access = True + elif partner.is_technician_portal: + task_count = request.env['fusion.technician.task'].sudo().search_count([ + ('sale_order_id', '=', order.id), + ('technician_id', '=', user.id), + ]) + if task_count: + has_access = True + if not has_access and not user.share: + has_access = True + if not has_access: + if partner.is_sales_rep_portal and order.user_id.id == user.id: + has_access = True + elif order.user_id.id == user.id: + has_access = True + + if not has_access: + return {'success': False, 'error': 'Access denied'} + + 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'} + + # Process signature data (remove data URL prefix if present) + if ',' in signature_data: + signature_data = signature_data.split(',')[1] + + # Parse signature date if provided + from datetime import datetime + sig_date = None + if signature_date: + try: + sig_date = datetime.strptime(signature_date, '%Y-%m-%d').date() + except ValueError: + pass # Leave as None if invalid + + # Determine if this is an ADP sale + is_adp = order.x_fc_sale_type in ('adp', 'adp_odsp') + + # Check if there's already an existing POD signature (for chatter logic) + had_existing_signature = bool(order.x_fc_pod_signature) + + # Save signature data + order.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': datetime.now(), + }) + + # Generate the signed POD PDF + # For ADP: save to x_fc_proof_of_delivery field + # For non-ADP: don't save to field, just generate for chatter + pdf_content = self._generate_signed_pod_pdf(order, save_to_field=is_adp) + + # Post to chatter + from markupsafe import Markup + date_str = sig_date.strftime('%B %d, %Y') if sig_date else 'Not specified' + pod_type = "ADP Proof of Delivery" if is_adp else "Proof of Delivery" + + if had_existing_signature: + # Update - post note about the update with new attachment + chatter_body = Markup(f''' +
+

{pod_type} Updated

+
    +
  • Client Name: {client_name.strip()}
  • +
  • Signature Date: {date_str}
  • +
  • Updated By: {user.name}
  • +
+

The POD document has been replaced with a new signed version.

+
+ ''') + # For non-ADP updates, still attach the new PDF + if not is_adp and pdf_content: + attachment = request.env['ir.attachment'].sudo().create({ + 'name': f'POD_{order.name.replace("/", "_")}.pdf', + 'type': 'binary', + 'datas': base64.b64encode(pdf_content), + 'res_model': 'sale.order', + 'res_id': order.id, + 'mimetype': 'application/pdf', + }) + order.message_post( + body=chatter_body, + message_type='notification', + subtype_xmlid='mail.mt_note', + attachment_ids=[attachment.id], + ) + else: + order.message_post(body=chatter_body, message_type='notification', subtype_xmlid='mail.mt_note') + else: + # New POD - post with attachment + chatter_body = Markup(f''' +
+

{pod_type} Signed

+
    +
  • Client Name: {client_name.strip()}
  • +
  • Signature Date: {date_str}
  • +
  • Collected By: {user.name}
  • +
+
+ ''') + + # Create attachment for the chatter (always for new POD) + if pdf_content: + attachment = request.env['ir.attachment'].sudo().create({ + 'name': f'POD_{order.name.replace("/", "_")}.pdf', + 'type': 'binary', + 'datas': base64.b64encode(pdf_content), + 'res_model': 'sale.order', + 'res_id': order.id, + 'mimetype': 'application/pdf', + }) + order.message_post( + body=chatter_body, + message_type='notification', + subtype_xmlid='mail.mt_note', + attachment_ids=[attachment.id], + ) + else: + order.message_post(body=chatter_body, message_type='notification', subtype_xmlid='mail.mt_note') + + return { + 'success': True, + 'message': 'Signature saved successfully', + 'redirect_url': f'/my/technician/delivery/{order_id}' if partner.is_technician_portal else f'/my/sales/case/{order_id}', + } + + except Exception as e: + _logger.error(f"Error saving 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. + + Args: + order: The sale.order record + save_to_field: If True, save to x_fc_proof_of_delivery field (for ADP orders) + + Returns: + bytes: The PDF content, or None if generation failed + """ + try: + # Determine which report to use based on sale type + is_adp = order.x_fc_sale_type in ('adp', 'adp_odsp') + + if is_adp: + report = request.env.ref('fusion_claims.action_report_proof_of_delivery') + else: + report = request.env.ref('fusion_claims.action_report_proof_of_delivery_standard') + + # Render the POD report (signature is now embedded in the template) + pdf_content, _ = report.sudo()._render_qweb_pdf( + report.id, [order.id] + ) + + # For ADP orders, save to the x_fc_proof_of_delivery field + if save_to_field and is_adp: + order.write({ + 'x_fc_proof_of_delivery': base64.b64encode(pdf_content), + 'x_fc_proof_of_delivery_filename': f'POD_{order.name.replace("/", "_")}.pdf', + }) + + _logger.info(f"Generated signed POD PDF for order {order.name} (ADP: {is_adp})") + return pdf_content + + except Exception as e: + _logger.error(f"Error generating POD PDF for {order.name}: {e}") + return None + + # ========================================================================= + # ACCESSIBILITY ASSESSMENT ROUTES + # ========================================================================= + + @http.route('/my/accessibility', type='http', auth='user', website=True) + def accessibility_assessment_selector(self, **kw): + """Show the accessibility assessment type selector""" + partner = request.env.user.partner_id + + if not partner.is_sales_rep_portal and not partner.is_authorizer: + return request.redirect('/my') + + # Get Google Maps API key + ICP = request.env['ir.config_parameter'].sudo() + google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '') + + values = { + 'page_name': 'accessibility_selector', + 'google_maps_api_key': google_maps_api_key, + } + return request.render('fusion_authorizer_portal.portal_accessibility_selector', values) + + @http.route('/my/accessibility/list', type='http', auth='user', website=True) + def accessibility_assessment_list(self, page=1, **kw): + """List all accessibility assessments for the current user (sales rep or authorizer)""" + partner = request.env.user.partner_id + + if not partner.is_sales_rep_portal and not partner.is_authorizer: + return request.redirect('/my') + + Assessment = request.env['fusion.accessibility.assessment'].sudo() + + # Build domain based on role + if partner.is_authorizer and partner.is_sales_rep_portal: + domain = ['|', ('authorizer_id', '=', partner.id), ('sales_rep_id', '=', request.env.user.id)] + elif partner.is_authorizer: + domain = [('authorizer_id', '=', partner.id)] + else: + domain = [('sales_rep_id', '=', request.env.user.id)] + + # Pagination + assessment_count = Assessment.search_count(domain) + pager = portal_pager( + url='/my/accessibility/list', + total=assessment_count, + page=page, + step=20, + ) + + assessments = Assessment.search( + domain, + order='assessment_date desc, id desc', + limit=20, + offset=pager['offset'], + ) + + values = { + 'page_name': 'accessibility_list', + 'assessments': assessments, + 'pager': pager, + } + return request.render('fusion_authorizer_portal.portal_accessibility_list', values) + + @http.route('/my/accessibility/stairlift/straight', type='http', auth='user', website=True) + def accessibility_stairlift_straight(self, **kw): + """Straight stair lift assessment form""" + return self._render_accessibility_form('stairlift_straight', 'Straight Stair Lift') + + @http.route('/my/accessibility/stairlift/curved', type='http', auth='user', website=True) + def accessibility_stairlift_curved(self, **kw): + """Curved stair lift assessment form""" + return self._render_accessibility_form('stairlift_curved', 'Curved Stair Lift') + + @http.route('/my/accessibility/vpl', type='http', auth='user', website=True) + def accessibility_vpl(self, **kw): + """Vertical Platform Lift assessment form""" + return self._render_accessibility_form('vpl', 'Vertical Platform Lift') + + @http.route('/my/accessibility/ceiling-lift', type='http', auth='user', website=True) + def accessibility_ceiling_lift(self, **kw): + """Ceiling Lift assessment form""" + return self._render_accessibility_form('ceiling_lift', 'Ceiling Lift') + + @http.route('/my/accessibility/ramp', type='http', auth='user', website=True) + def accessibility_ramp(self, **kw): + """Custom Ramp assessment form""" + return self._render_accessibility_form('ramp', 'Custom Ramp') + + @http.route('/my/accessibility/bathroom', type='http', auth='user', website=True) + def accessibility_bathroom(self, **kw): + """Bathroom Modification assessment form""" + return self._render_accessibility_form('bathroom', 'Bathroom Modification') + + @http.route('/my/accessibility/tub-cutout', type='http', auth='user', website=True) + def accessibility_tub_cutout(self, **kw): + """Tub Cutout assessment form""" + return self._render_accessibility_form('tub_cutout', 'Tub Cutout') + + def _render_accessibility_form(self, assessment_type, title): + """Render an accessibility assessment form""" + partner = request.env.user.partner_id + + if not partner.is_sales_rep_portal and not partner.is_authorizer: + return request.redirect('/my') + + # Get Google Maps API key + ICP = request.env['ir.config_parameter'].sudo() + google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '') + + from datetime import date + + values = { + 'page_name': f'accessibility_{assessment_type}', + 'assessment_type': assessment_type, + 'title': title, + 'google_maps_api_key': google_maps_api_key, + 'today': date.today().isoformat(), + } + + # Route to specific template based on type + template_map = { + 'stairlift_straight': 'fusion_authorizer_portal.portal_accessibility_stairlift_straight', + 'stairlift_curved': 'fusion_authorizer_portal.portal_accessibility_stairlift_curved', + 'vpl': 'fusion_authorizer_portal.portal_accessibility_vpl', + 'ceiling_lift': 'fusion_authorizer_portal.portal_accessibility_ceiling', + 'ramp': 'fusion_authorizer_portal.portal_accessibility_ramp', + 'bathroom': 'fusion_authorizer_portal.portal_accessibility_bathroom', + 'tub_cutout': 'fusion_authorizer_portal.portal_accessibility_tub_cutout', + } + + template = template_map.get(assessment_type, 'fusion_authorizer_portal.portal_accessibility_selector') + return request.render(template, values) + + @http.route('/my/accessibility/save', type='json', auth='user', methods=['POST'], csrf=True) + def accessibility_assessment_save(self, **post): + """Save an accessibility assessment and optionally create a Sale Order""" + partner = request.env.user.partner_id + + if not partner.is_sales_rep_portal and not partner.is_authorizer: + return {'success': False, 'error': 'Access denied'} + + try: + Assessment = request.env['fusion.accessibility.assessment'].sudo() + + assessment_type = post.get('assessment_type') + if not assessment_type: + return {'success': False, 'error': 'Assessment type is required'} + + # Build assessment values + vals = { + 'assessment_type': assessment_type, + 'sales_rep_id': request.env.user.id, + 'client_name': post.get('client_name', '').strip(), + 'client_address': post.get('client_address', '').strip(), + 'client_unit': post.get('client_unit', '').strip(), + 'client_address_street': post.get('client_address_street', '').strip(), + 'client_address_city': post.get('client_address_city', '').strip(), + 'client_address_province': post.get('client_address_province', '').strip(), + 'client_address_postal': post.get('client_address_postal', '').strip(), + 'client_phone': post.get('client_phone', '').strip(), + 'client_email': post.get('client_email', '').strip(), + 'notes': post.get('notes', '').strip(), + } + + # Parse assessment date + assessment_date = post.get('assessment_date') + if assessment_date: + from datetime import date as dt_date + try: + vals['assessment_date'] = dt_date.fromisoformat(assessment_date) + except ValueError: + vals['assessment_date'] = dt_date.today() + + # Add type-specific fields + if assessment_type == 'stairlift_straight': + vals.update(self._parse_stairlift_straight_fields(post)) + elif assessment_type == 'stairlift_curved': + vals.update(self._parse_stairlift_curved_fields(post)) + elif assessment_type == 'vpl': + vals.update(self._parse_vpl_fields(post)) + elif assessment_type == 'ceiling_lift': + vals.update(self._parse_ceiling_lift_fields(post)) + elif assessment_type == 'ramp': + vals.update(self._parse_ramp_fields(post)) + elif assessment_type == 'bathroom': + vals.update(self._parse_bathroom_fields(post)) + elif assessment_type == 'tub_cutout': + vals.update(self._parse_tub_cutout_fields(post)) + + # Set authorizer if the current user is an authorizer, or from the linked sale order + if partner.is_authorizer: + vals['authorizer_id'] = partner.id + + # Create the assessment + assessment = Assessment.create(vals) + _logger.info(f"Created accessibility assessment {assessment.reference} by {request.env.user.name}") + + # Handle photo attachments - General photos + photos = post.get('photos', []) + if photos: + self._attach_accessibility_photos(assessment, photos, category='general') + + # Handle top landing photos (for curved stair lifts) + top_landing_photos = post.get('top_landing_photos', []) + if top_landing_photos: + self._attach_accessibility_photos(assessment, top_landing_photos, category='top_landing') + + # Handle bottom landing photos (for curved stair lifts) + bottom_landing_photos = post.get('bottom_landing_photos', []) + if bottom_landing_photos: + self._attach_accessibility_photos(assessment, bottom_landing_photos, category='bottom_landing') + + # Handle video attachment + video_data = post.get('assessment_video') + video_filename = post.get('assessment_video_filename') + if video_data: + self._attach_accessibility_video(assessment, video_data, video_filename) + + # Complete the assessment and create Sale Order if requested + create_sale_order = post.get('create_sale_order', True) + if create_sale_order: + sale_order = assessment.action_complete() + return { + 'success': True, + 'assessment_id': assessment.id, + 'assessment_ref': assessment.reference, + 'sale_order_id': sale_order.id, + 'sale_order_name': sale_order.name, + 'message': f'Assessment {assessment.reference} completed. Sale Order {sale_order.name} created.', + 'redirect_url': f'/my/sales/case/{sale_order.id}', + } + else: + return { + 'success': True, + 'assessment_id': assessment.id, + 'assessment_ref': assessment.reference, + 'message': f'Assessment {assessment.reference} saved as draft.', + 'redirect_url': '/my/accessibility/list', + } + + except Exception as e: + _logger.error(f"Error saving accessibility assessment: {e}") + return {'success': False, 'error': str(e)} + + def _parse_stairlift_straight_fields(self, post): + """Parse straight stair lift specific fields""" + return { + 'stair_steps': int(post.get('stair_steps', 0) or 0), + 'stair_nose_to_nose': float(post.get('stair_nose_to_nose', 0) or 0), + 'stair_side': post.get('stair_side') or None, + 'stair_style': post.get('stair_style') or None, + 'stair_power_swivel_upstairs': post.get('stair_power_swivel_upstairs') == 'true', + 'stair_power_folding_footrest': post.get('stair_power_folding_footrest') == 'true', + 'stair_manual_length_override': float(post.get('stair_manual_length_override', 0) or 0), + } + + def _parse_stairlift_curved_fields(self, post): + """Parse curved stair lift specific fields""" + return { + 'stair_curved_steps': int(post.get('stair_curved_steps', 0) or 0), + 'stair_curves_count': int(post.get('stair_curves_count', 0) or 0), + 'stair_top_landing_type': post.get('stair_top_landing_type') or 'none', + 'stair_bottom_landing_type': post.get('stair_bottom_landing_type') or 'none', + 'top_overrun_custom_length': float(post.get('top_overrun_custom_length', 0) or 0), + 'bottom_overrun_custom_length': float(post.get('bottom_overrun_custom_length', 0) or 0), + 'stair_power_swivel_upstairs': post.get('stair_power_swivel_upstairs') == 'true', + 'stair_power_swivel_downstairs': post.get('stair_power_swivel_downstairs') == 'true', + 'stair_auto_folding_footrest': post.get('stair_auto_folding_footrest') == 'true', + 'stair_auto_folding_hinge': post.get('stair_auto_folding_hinge') == 'true', + 'stair_auto_folding_seat': post.get('stair_auto_folding_seat') == 'true', + 'stair_custom_color': post.get('stair_custom_color') == 'true', + 'stair_additional_charging': post.get('stair_additional_charging') == 'true', + 'stair_charging_with_remote': post.get('stair_charging_with_remote') == 'true', + 'stair_curved_manual_override': float(post.get('stair_curved_manual_override', 0) or 0), + } + + def _parse_vpl_fields(self, post): + """Parse VPL specific fields""" + return { + 'vpl_room_width': float(post.get('vpl_room_width', 0) or 0), + 'vpl_room_depth': float(post.get('vpl_room_depth', 0) or 0), + 'vpl_rise_height': float(post.get('vpl_rise_height', 0) or 0), + 'vpl_has_existing_platform': post.get('vpl_has_existing_platform') == 'true', + 'vpl_concrete_depth': float(post.get('vpl_concrete_depth', 0) or 0), + 'vpl_model_type': post.get('vpl_model_type') or None, + 'vpl_has_nearby_plug': post.get('vpl_has_nearby_plug') == 'true', + 'vpl_needs_plug_install': post.get('vpl_needs_plug_install') == 'true', + 'vpl_needs_certification': post.get('vpl_needs_certification') == 'true', + 'vpl_certification_notes': post.get('vpl_certification_notes', '').strip(), + } + + def _parse_ceiling_lift_fields(self, post): + """Parse ceiling lift specific fields""" + return { + 'ceiling_track_length': float(post.get('ceiling_track_length', 0) or 0), + 'ceiling_movement_type': post.get('ceiling_movement_type') or None, + 'ceiling_charging_throughout': post.get('ceiling_charging_throughout') == 'true', + 'ceiling_carry_bar': post.get('ceiling_carry_bar') == 'true', + 'ceiling_additional_slings': int(post.get('ceiling_additional_slings', 0) or 0), + } + + def _parse_ramp_fields(self, post): + """Parse ramp specific fields""" + return { + 'ramp_height': float(post.get('ramp_height', 0) or 0), + 'ramp_ground_incline': float(post.get('ramp_ground_incline', 0) or 0), + 'ramp_at_door': post.get('ramp_at_door') == 'true', + 'ramp_handrail_height': float(post.get('ramp_handrail_height', 32) or 32), + 'ramp_manual_override': float(post.get('ramp_manual_override', 0) or 0), + } + + def _parse_bathroom_fields(self, post): + """Parse bathroom modification specific fields""" + return { + 'bathroom_description': post.get('bathroom_description', '').strip(), + } + + def _parse_tub_cutout_fields(self, post): + """Parse tub cutout specific fields""" + return { + 'tub_internal_height': float(post.get('tub_internal_height', 0) or 0), + 'tub_external_height': float(post.get('tub_external_height', 0) or 0), + 'tub_additional_supplies': post.get('tub_additional_supplies', '').strip(), + } + + def _attach_accessibility_photos(self, assessment, photos, category='general'): + """Attach photos to the accessibility assessment + + Args: + assessment: The assessment record + photos: List of base64 encoded photo data + category: Photo category (general, top_landing, bottom_landing) + """ + Attachment = request.env['ir.attachment'].sudo() + + # Category prefix for file naming + category_prefixes = { + 'general': 'Photo', + 'top_landing': 'TopLanding', + 'bottom_landing': 'BottomLanding', + } + prefix = category_prefixes.get(category, 'Photo') + + for i, photo_data in enumerate(photos): + if not photo_data: + continue + + # Handle base64 data URL format + if ',' in photo_data: + photo_data = photo_data.split(',')[1] + + try: + attachment = Attachment.create({ + 'name': f'{prefix}_{i+1}_{assessment.reference}.jpg', + 'type': 'binary', + 'datas': photo_data, + 'res_model': 'fusion.accessibility.assessment', + 'res_id': assessment.id, + 'mimetype': 'image/jpeg', + 'description': f'{category.replace("_", " ").title()} photo for {assessment.reference}', + }) + _logger.info(f"Attached {category} photo {i+1} to assessment {assessment.reference}") + except Exception as e: + _logger.warning(f"Failed to attach {category} photo {i+1}: {e}") + + def _attach_accessibility_video(self, assessment, video_data, video_filename=None): + """Attach a video to the accessibility assessment + + Args: + assessment: The assessment record + video_data: Base64 encoded video data + video_filename: Original filename (optional) + """ + if not video_data: + return + + Attachment = request.env['ir.attachment'].sudo() + + # Handle base64 data URL format + mimetype = 'video/mp4' + if isinstance(video_data, str) and ',' in video_data: + # Extract mimetype from data URL + header = video_data.split(',')[0] + if 'video/' in header: + mimetype = header.split(':')[1].split(';')[0] + video_data = video_data.split(',')[1] + + # Determine file extension from mimetype + extension_map = { + 'video/mp4': '.mp4', + 'video/webm': '.webm', + 'video/quicktime': '.mov', + 'video/x-msvideo': '.avi', + } + extension = extension_map.get(mimetype, '.mp4') + + filename = video_filename or f'Video_{assessment.reference}{extension}' + + try: + attachment = Attachment.create({ + 'name': filename, + 'type': 'binary', + 'datas': video_data, + 'res_model': 'fusion.accessibility.assessment', + 'res_id': assessment.id, + 'mimetype': mimetype, + 'description': f'Assessment video for {assessment.reference}', + }) + _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}") diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/data/ir_actions_server_data.xml b/fusion_authorizer_portal/fusion_authorizer_portal/data/ir_actions_server_data.xml new file mode 100644 index 0000000..d78d3cc --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/data/ir_actions_server_data.xml @@ -0,0 +1,42 @@ + + + + + + + + Mark as Authorizer + + + list + code + +action = records.action_mark_as_authorizer() + + + + + + Send Portal Invitation + + + list + code + +action = records.action_batch_send_portal_invitation() + + + + + + Mark as Authorizer & Send Invitation + + + list + code + +action = records.action_mark_and_send_invitation() + + + + diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/data/mail_template_data.xml b/fusion_authorizer_portal/fusion_authorizer_portal/data/mail_template_data.xml new file mode 100644 index 0000000..c26a4da --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/data/mail_template_data.xml @@ -0,0 +1,172 @@ + + + + + + + Assessment Sequence + fusion.assessment + ASM- + 5 + 1 + 1 + + + + + Accessibility Assessment Sequence + fusion.accessibility.assessment + ACC- + 5 + 1 + 1 + + + + + + + + Authorizer Portal: Case Assigned + + New Case Assigned: {{ object.name }} - {{ object.partner_id.name }} + {{ (object.company_id.email or object.user_id.email or 'noreply@example.com') }} + {{ object.x_fc_authorizer_id.email }} + +
+
+
+

+

New Case Assigned

+

A new ADP case has been assigned to you.

+ + + + + +
Case Details
Case
Client
Date
+

View in Portal

+

Best regards,

+
+

This is an automated notification from .

+
+
+ +
+ + + + Authorizer Portal: Status Changed (Disabled) + + Case Update: {{ object.name }} +

This template is no longer in use.

+ +
+ + + + + + Assessment Complete - Authorizer Notification + + Assessment Complete: {{ object.reference }} - {{ object.client_name }} + {{ (object.sales_rep_id.company_id.email or 'noreply@example.com') }} + {{ object.authorizer_id.email }} + +
+
+
+

+

Assessment Complete

+

The assessment for has been completed and a sale order has been created.

+ + + + + + + + +
Assessment Details
Reference
Client
Date
Sale Order
+
+

Next steps: Please submit the ADP application (including pages 11-12 signed by the client) so we can proceed with the claim submission.

+
+

View in Portal

+

Best regards,

+
+

This is an automated notification from the ADP Claims Management System.

+
+
+ +
+ + + + + + Assessment Complete - Client Notification + + Your Assessment is Complete - {{ object.reference }} + {{ (object.sales_rep_id.company_id.email or 'noreply@example.com') }} + {{ object.client_email }} + +
+
+
+

+

Assessment Complete

+

Dear , thank you for completing your assessment with us.

+ + + + + + + +
Summary
Reference
Date
Therapist
+
+

What happens next:

+
    +
  1. Your assessment will be reviewed by our team
  2. +
  3. We will submit the ADP application on your behalf
  4. +
  5. You will be notified once approval is received
  6. +
  7. Your equipment will be ordered and delivered
  8. +
+
+

If you have any questions, please do not hesitate to contact us.

+

Best regards,

+
+

This is an automated notification from the ADP Claims Management System.

+
+
+ +
+ + + + + + Authorizer Portal: Document Uploaded + + New Document Uploaded: {{ object.name }} + {{ (object.company_id.email or 'noreply@example.com') }} + {{ object.x_fc_authorizer_id.email }} + +
+
+
+

+

New Document Available

+

A new document has been uploaded for case ().

+

View in Portal

+

Best regards,

+
+

This is an automated notification from .

+
+
+ +
+ +
diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/data/portal_menu_data.xml b/fusion_authorizer_portal/fusion_authorizer_portal/data/portal_menu_data.xml new file mode 100644 index 0000000..b42b421 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/data/portal_menu_data.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/data/welcome_articles.xml b/fusion_authorizer_portal/fusion_authorizer_portal/data/welcome_articles.xml new file mode 100644 index 0000000..dfbdf68 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/data/welcome_articles.xml @@ -0,0 +1,432 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/models/__init__.py b/fusion_authorizer_portal/fusion_authorizer_portal/models/__init__.py new file mode 100644 index 0000000..297f041 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/models/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- + +from . import res_partner +from . import res_users +from . import authorizer_comment +from . import adp_document +from . import assessment +from . import accessibility_assessment +from . import sale_order +from . import pdf_template \ No newline at end of file diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/models/accessibility_assessment.py b/fusion_authorizer_portal/fusion_authorizer_portal/models/accessibility_assessment.py new file mode 100644 index 0000000..07a0fa7 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/models/accessibility_assessment.py @@ -0,0 +1,874 @@ +# -*- coding: utf-8 -*- + +import logging +import math +from datetime import timedelta +from markupsafe import Markup +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class FusionAccessibilityAssessment(models.Model): + _name = 'fusion.accessibility.assessment' + _description = 'Accessibility Assessment' + _inherit = ['mail.thread', 'mail.activity.mixin', 'fusion.email.builder.mixin'] + _order = 'assessment_date desc, id desc' + _rec_name = 'display_name' + + # ========================================================================== + # COMMON FIELDS (all assessment types) + # ========================================================================== + reference = fields.Char( + string='Reference', + readonly=True, + copy=False, + default=lambda self: _('New'), + ) + + display_name = fields.Char( + compute='_compute_display_name', + store=True, + ) + + assessment_type = fields.Selection( + selection=[ + ('stairlift_straight', 'Straight Stair Lift'), + ('stairlift_curved', 'Curved Stair Lift'), + ('vpl', 'Vertical Platform Lift'), + ('ceiling_lift', 'Ceiling Lift'), + ('ramp', 'Custom Ramp'), + ('bathroom', 'Bathroom Modification'), + ('tub_cutout', 'Tub Cutout'), + ], + string='Assessment Type', + required=True, + tracking=True, + ) + + state = fields.Selection( + selection=[ + ('draft', 'Draft'), + ('completed', 'Completed'), + ('cancelled', 'Cancelled'), + ], + string='Status', + default='draft', + tracking=True, + ) + + # Client Information + client_name = fields.Char(string='Client Name', required=True) + client_address = fields.Char(string='Address') + client_unit = fields.Char(string='Unit/Apt/Suite') + client_address_street = fields.Char(string='Street') + client_address_city = fields.Char(string='City') + client_address_province = fields.Char(string='Province') + client_address_postal = fields.Char(string='Postal Code') + client_phone = fields.Char(string='Phone') + client_email = fields.Char(string='Email') + + # Booking fields + booking_source = fields.Selection( + selection=[ + ('phone_authorizer', 'Phone - Authorizer'), + ('phone_client', 'Phone - Client'), + ('walk_in', 'Walk-In'), + ('portal', 'Online Booking'), + ], + string='Booking Source', + default='phone_client', + help='How the assessment was booked', + ) + modification_requested = fields.Text( + string='Modification Requested', + help='What the client or authorizer is looking for', + ) + sms_confirmation_sent = fields.Boolean( + string='SMS Confirmation Sent', + default=False, + ) + calendar_event_id = fields.Many2one( + 'calendar.event', + string='Calendar Event', + readonly=True, + copy=False, + ) + + # Relationships + sales_rep_id = fields.Many2one( + 'res.users', + string='Sales Rep', + default=lambda self: self.env.user, + tracking=True, + ) + authorizer_id = fields.Many2one( + 'res.partner', + string='Authorizer/OT', + tracking=True, + help='The Occupational Therapist or Authorizer for this assessment', + ) + partner_id = fields.Many2one( + 'res.partner', + string='Client Partner', + help='Linked partner record (created on completion)', + ) + sale_order_id = fields.Many2one( + 'sale.order', + string='Created Sale Order', + readonly=True, + copy=False, + ) + + # Dates + assessment_date = fields.Date( + string='Assessment Date', + default=fields.Date.today, + ) + + # General Notes + notes = fields.Text(string='General Notes') + + # ========================================================================== + # STAIR LIFT - STRAIGHT FIELDS + # ========================================================================== + stair_steps = fields.Integer(string='Number of Steps') + stair_nose_to_nose = fields.Float(string='Nose to Nose Distance (inches)') + stair_side = fields.Selection( + selection=[('left', 'Left'), ('right', 'Right')], + string='Installation Side', + ) + stair_style = fields.Selection( + selection=[ + ('standard', 'Standard Stair Lift'), + ('slide_track', 'Slide Track Stair Lift'), + ('foldable_hinge', 'Foldable Hinge Stair Lift'), + ], + string='Stair Lift Style', + ) + stair_power_swivel_upstairs = fields.Boolean(string='Power Swivel (Upstairs)') + stair_power_folding_footrest = fields.Boolean(string='Power Folding Footrest') + stair_calculated_length = fields.Float( + string='Calculated Track Length (inches)', + compute='_compute_stair_straight_length', + store=True, + ) + stair_manual_length_override = fields.Float(string='Manual Length Override (inches)') + stair_final_length = fields.Float( + string='Final Track Length (inches)', + compute='_compute_stair_final_length', + store=True, + ) + + # ========================================================================== + # STAIR LIFT - CURVED FIELDS + # ========================================================================== + stair_curved_steps = fields.Integer(string='Number of Steps (Curved)') + stair_curves_count = fields.Integer(string='Number of Curves') + + # Top Landing Options + stair_top_landing_type = fields.Selection( + selection=[ + ('none', 'Standard (No special landing)'), + ('90_exit', '90° Exit'), + ('90_parking', '90° Parking'), + ('180_parking', '180° Parking'), + ('flush_landing', 'Flush Landing'), + ('vertical_overrun', 'Vertical Overrun (Custom)'), + ], + string='Top Landing Type', + default='none', + help='Type of landing at the top of the staircase', + ) + top_overrun_custom_length = fields.Float( + string='Top Overrun Length (inches)', + help='Custom overrun length when Vertical Overrun is selected', + ) + + # Bottom Landing Options + stair_bottom_landing_type = fields.Selection( + selection=[ + ('none', 'Standard (No special landing)'), + ('90_park', '90° Park'), + ('180_park', '180° Park'), + ('drop_nose', 'Drop Nose Landing'), + ('short_vertical', 'Short Vertical Start'), + ('horizontal_overrun', 'Horizontal Overrun (Custom)'), + ], + string='Bottom Landing Type', + default='none', + help='Type of landing at the bottom of the staircase', + ) + bottom_overrun_custom_length = fields.Float( + string='Bottom Overrun Length (inches)', + help='Custom overrun length when Horizontal Overrun is selected', + ) + + # Legacy fields kept for backwards compatibility + stair_has_drop_nose = fields.Boolean(string='Has Drop Nose (Legacy)') + stair_parking_type = fields.Selection( + selection=[ + ('none', 'No Parking'), + ('90_degree', '90° Parking (+2 feet)'), + ('180_degree', '180° Parking (+4 feet)'), + ], + string='Parking Type (Legacy)', + default='none', + ) + stair_power_swivel_downstairs = fields.Boolean(string='Power Swivel (Downstairs)') + stair_auto_folding_footrest = fields.Boolean(string='Automatic Folding Footrest') + stair_auto_folding_hinge = fields.Boolean(string='Automatic Folding Hinge') + stair_auto_folding_seat = fields.Boolean(string='Automatic Folding Seat') + stair_custom_color = fields.Boolean(string='Customizable Colored Seat') + stair_additional_charging = fields.Boolean(string='Additional Charging Station') + stair_charging_with_remote = fields.Boolean(string='Charging Station with Remote') + stair_curved_calculated_length = fields.Float( + string='Calculated Track Length (inches)', + compute='_compute_stair_curved_length', + store=True, + ) + stair_curved_manual_override = fields.Float(string='Manual Length Override (inches)') + stair_curved_final_length = fields.Float( + string='Final Track Length (inches)', + compute='_compute_stair_curved_final_length', + store=True, + ) + + # ========================================================================== + # VERTICAL PLATFORM LIFT (VPL) FIELDS + # ========================================================================== + vpl_room_width = fields.Float(string='Room Width (inches)') + vpl_room_depth = fields.Float(string='Room Depth (inches)') + vpl_rise_height = fields.Float(string='Total Rise Height (inches)') + vpl_has_existing_platform = fields.Boolean(string='Existing Platform Available') + vpl_concrete_depth = fields.Float(string='Concrete Depth (inches)', help='Minimum 4 inches required') + vpl_model_type = fields.Selection( + selection=[ + ('ac', 'AC Model (Dedicated 15-amp breaker required)'), + ('dc', 'DC Model (No dedicated breaker required)'), + ], + string='Model Type', + ) + vpl_has_nearby_plug = fields.Boolean(string='Power Plug Nearby') + vpl_plug_specs = fields.Char(string='Plug Specifications', default='110V / 15-amp') + vpl_needs_plug_install = fields.Boolean(string='Needs Plug Installation') + vpl_needs_certification = fields.Boolean(string='Needs City Certification') + vpl_certification_notes = fields.Text(string='Certification Notes') + + # ========================================================================== + # CEILING LIFT FIELDS + # ========================================================================== + ceiling_track_length = fields.Float(string='Total Track Length (feet)') + ceiling_movement_type = fields.Selection( + selection=[ + ('manual', 'Manual Movement (left-to-right)'), + ('powered', 'Powered Movement (left-to-right)'), + ], + string='Horizontal Movement Type', + help='All ceiling lifts move up/down with power. This is for left-to-right movement.', + ) + ceiling_charging_throughout = fields.Boolean( + string='Charging Throughout Track', + help='Charging available throughout the track instead of one location', + ) + ceiling_carry_bar = fields.Boolean(string='Carry Bar') + ceiling_additional_slings = fields.Integer(string='Additional Slings Needed') + + # ========================================================================== + # CUSTOM RAMP FIELDS + # ========================================================================== + ramp_height = fields.Float(string='Total Height (inches from ground)') + ramp_ground_incline = fields.Float(string='Ground Incline (degrees)', help='Optional - if ground is inclined') + ramp_at_door = fields.Boolean(string='Ramp at Door', help='Requires 5ft landing at door') + ramp_calculated_length = fields.Float( + string='Calculated Ramp Length (inches)', + compute='_compute_ramp_length', + store=True, + help='Ontario Building Code: 12 inches length per 1 inch height', + ) + ramp_landings_needed = fields.Integer( + string='Landings Needed', + compute='_compute_ramp_landings', + store=True, + help='Landing required every 30 feet (minimum 5 feet each)', + ) + ramp_total_length = fields.Float( + string='Total Length with Landings (inches)', + compute='_compute_ramp_total_length', + store=True, + ) + ramp_handrail_height = fields.Float( + string='Handrail Height (inches)', + default=32.0, + help='Minimum 32 inches required', + ) + ramp_manual_override = fields.Float(string='Manual Length Override (inches)') + + # ========================================================================== + # BATHROOM MODIFICATION FIELDS + # ========================================================================== + bathroom_description = fields.Text( + string='Modification Description', + help='Describe all bathroom modifications needed', + ) + + # ========================================================================== + # TUB CUTOUT FIELDS + # ========================================================================== + tub_internal_height = fields.Float(string='Internal Height of Tub (inches)') + tub_external_height = fields.Float(string='External Height of Tub (inches)') + tub_additional_supplies = fields.Text(string='Additional Supplies Needed') + + # ========================================================================== + # COMPUTED FIELDS + # ========================================================================== + + @api.depends('reference', 'assessment_type', 'client_name') + def _compute_display_name(self): + type_labels = dict(self._fields['assessment_type'].selection) + for rec in self: + type_label = type_labels.get(rec.assessment_type, '') + rec.display_name = f"{rec.reference or 'New'} - {type_label} - {rec.client_name or ''}" + + @api.depends('stair_steps', 'stair_nose_to_nose') + def _compute_stair_straight_length(self): + """Straight stair lift: (steps × nose_to_nose) + 13" top landing""" + for rec in self: + if rec.stair_steps and rec.stair_nose_to_nose: + rec.stair_calculated_length = (rec.stair_steps * rec.stair_nose_to_nose) + 13 + else: + rec.stair_calculated_length = 0 + + @api.depends('stair_calculated_length', 'stair_manual_length_override') + def _compute_stair_final_length(self): + """Use manual override if provided, otherwise use calculated""" + for rec in self: + if rec.stair_manual_length_override: + rec.stair_final_length = rec.stair_manual_length_override + else: + rec.stair_final_length = rec.stair_calculated_length + + @api.depends('stair_curved_steps', 'stair_curves_count', + 'stair_top_landing_type', 'stair_bottom_landing_type', + 'top_overrun_custom_length', 'bottom_overrun_custom_length') + def _compute_stair_curved_length(self): + """Curved stair lift calculation: + - 12" per step + - 16" per curve + - Top landing type additions (or custom overrun) + - Bottom landing type additions (or custom overrun) + """ + # Track length additions for each landing type (in inches) + # Note: vertical_overrun and horizontal_overrun use custom lengths + TOP_LANDING_LENGTHS = { + 'none': 0, + '90_exit': 24, # 2 feet + '90_parking': 24, # 2 feet + '180_parking': 48, # 4 feet + 'flush_landing': 12, # 1 foot + } + + BOTTOM_LANDING_LENGTHS = { + 'none': 0, + '90_park': 24, # 2 feet + '180_park': 48, # 4 feet + 'drop_nose': 12, # 1 foot + 'short_vertical': 12, # 1 foot + } + + for rec in self: + if rec.stair_curved_steps: + base_length = rec.stair_curved_steps * 12 # 12" per step + curves_length = (rec.stair_curves_count or 0) * 16 # 16" per curve + + # Top landing length - use custom if overrun selected + if rec.stair_top_landing_type == 'vertical_overrun': + top_landing = rec.top_overrun_custom_length or 0 + else: + top_landing = TOP_LANDING_LENGTHS.get(rec.stair_top_landing_type or 'none', 0) + + # Bottom landing length - use custom if overrun selected + if rec.stair_bottom_landing_type == 'horizontal_overrun': + bottom_landing = rec.bottom_overrun_custom_length or 0 + else: + bottom_landing = BOTTOM_LANDING_LENGTHS.get(rec.stair_bottom_landing_type or 'none', 0) + + rec.stair_curved_calculated_length = ( + base_length + curves_length + top_landing + bottom_landing + ) + else: + rec.stair_curved_calculated_length = 0 + + @api.depends('stair_curved_calculated_length', 'stair_curved_manual_override') + def _compute_stair_curved_final_length(self): + """Use manual override if provided, otherwise use calculated""" + for rec in self: + if rec.stair_curved_manual_override: + rec.stair_curved_final_length = rec.stair_curved_manual_override + else: + rec.stair_curved_final_length = rec.stair_curved_calculated_length + + @api.depends('ramp_height') + def _compute_ramp_length(self): + """Ontario Building Code: 12 inches length per 1 inch height (1:12 ratio)""" + for rec in self: + if rec.ramp_height: + rec.ramp_calculated_length = rec.ramp_height * 12 + else: + rec.ramp_calculated_length = 0 + + @api.depends('ramp_calculated_length') + def _compute_ramp_landings(self): + """Landing required every 30 feet (360 inches)""" + for rec in self: + if rec.ramp_calculated_length: + # Calculate how many landings are needed (every 30 feet = 360 inches) + rec.ramp_landings_needed = math.ceil(rec.ramp_calculated_length / 360) + else: + rec.ramp_landings_needed = 0 + + @api.depends('ramp_calculated_length', 'ramp_landings_needed', 'ramp_at_door') + def _compute_ramp_total_length(self): + """Total length including landings (5 feet = 60 inches each)""" + for rec in self: + base_length = rec.ramp_calculated_length or 0 + landings_length = (rec.ramp_landings_needed or 0) * 60 # 5 feet per landing + door_landing = 60 if rec.ramp_at_door else 0 # 5 feet at door + rec.ramp_total_length = base_length + landings_length + door_landing + + # ========================================================================== + # CRUD METHODS + # ========================================================================== + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('reference', _('New')) == _('New'): + vals['reference'] = self.env['ir.sequence'].next_by_code( + 'fusion.accessibility.assessment' + ) or _('New') + return super().create(vals_list) + + # ========================================================================== + # BUSINESS LOGIC + # ========================================================================== + + def action_complete(self): + """Complete the assessment and create a Sale Order""" + self.ensure_one() + + if not self.client_name: + raise UserError(_('Please enter the client name.')) + + # Create or find partner + partner = self._ensure_partner() + + # Create draft sale order + sale_order = self._create_draft_sale_order(partner) + + # Add tag based on assessment type + self._add_assessment_tag(sale_order) + + # Copy photos from assessment to sale order chatter + self._copy_photos_to_sale_order(sale_order) + + # Update state + self.write({ + 'state': 'completed', + 'sale_order_id': sale_order.id, + 'partner_id': partner.id, + }) + + # Send email notification to office + self._send_completion_email(sale_order) + + # Schedule follow-up activity for sales rep + self._schedule_followup_activity(sale_order) + + _logger.info(f"Completed accessibility assessment {self.reference}, created SO {sale_order.name}") + + return sale_order + + def _add_assessment_tag(self, sale_order): + """Add a tag to the sale order based on assessment type""" + self.ensure_one() + + # Map assessment types to tag names (ALL CAPS) + tag_map = { + 'stairlift_straight': 'STRAIGHT STAIR LIFT', + 'stairlift_curved': 'CURVED STAIR LIFT', + 'vpl': 'VERTICAL PLATFORM LIFT', + 'ceiling_lift': 'CEILING LIFT', + 'ramp': 'CUSTOM RAMP', + 'bathroom': 'BATHROOM MODIFICATION', + 'tub_cutout': 'TUB CUTOUT', + } + + tag_name = tag_map.get(self.assessment_type) + if not tag_name: + return + + # Find or create the tag + Tag = self.env['crm.tag'].sudo() + tag = Tag.search([('name', '=', tag_name)], limit=1) + if not tag: + tag = Tag.create({'name': tag_name}) + _logger.info(f"Created new tag: {tag_name}") + + # Add tag to sale order + if hasattr(sale_order, 'tag_ids'): + sale_order.write({'tag_ids': [(4, tag.id)]}) + _logger.info(f"Added tag '{tag_name}' to SO {sale_order.name}") + + def _copy_photos_to_sale_order(self, sale_order): + """Copy assessment photos to sale order chatter""" + self.ensure_one() + + Attachment = self.env['ir.attachment'].sudo() + + # Find photos attached to this assessment + photos = Attachment.search([ + ('res_model', '=', 'fusion.accessibility.assessment'), + ('res_id', '=', self.id), + ('mimetype', 'like', 'image/%'), + ]) + + if not photos: + return + + # Copy attachments to sale order and post in chatter + attachment_ids = [] + for photo in photos: + new_attachment = photo.copy({ + 'res_model': 'sale.order', + 'res_id': sale_order.id, + }) + attachment_ids.append(new_attachment.id) + + if attachment_ids: + type_labels = dict(self._fields['assessment_type'].selection) + type_label = type_labels.get(self.assessment_type, 'Accessibility') + + sale_order.message_post( + body=Markup(f''' +
+ Assessment Photos
+ {len(attachment_ids)} photo(s) from {type_label} Assessment ({self.reference}) +
+ '''), + message_type='comment', + subtype_xmlid='mail.mt_note', + attachment_ids=attachment_ids, + ) + _logger.info(f"Copied {len(attachment_ids)} photos to SO {sale_order.name}") + + def _send_completion_email(self, sale_order): + """Send email notification to office about assessment completion""" + self.ensure_one() + + ICP = self.env['ir.config_parameter'].sudo() + + # Check if email notifications are enabled + if not ICP.get_param('fusion_claims.enable_email_notifications', 'True') == 'True': + return + + # Get office notification emails from company + company = self.env.company + office_partners = company.sudo().x_fc_office_notification_ids + email_list = [p.email for p in office_partners if p.email] + office_emails = ', '.join(email_list) + + if not office_emails: + _logger.warning("No office notification recipients configured for accessibility assessment completion") + return + + type_labels = dict(self._fields['assessment_type'].selection) + type_label = type_labels.get(self.assessment_type, 'Accessibility') + + body = self._email_build( + title='Accessibility Assessment Completed', + summary=f'A new {type_label.lower()} assessment has been completed for ' + f'{self.client_name}. A sale order has been created.', + email_type='info', + sections=[('Assessment Details', [ + ('Type', type_label), + ('Reference', self.reference), + ('Client', self.client_name), + ('Sales Rep', self.sales_rep_id.name if self.sales_rep_id else 'N/A'), + ('Sale Order', sale_order.name), + ])], + button_url=f'{sale_order.get_base_url()}/web#id={sale_order.id}&model=sale.order&view_type=form', + button_text='View Sale Order', + ) + + # Send email + mail_values = { + 'subject': f'Accessibility Assessment Completed: {type_label} - {self.client_name}', + 'body_html': body, + 'email_to': office_emails, + 'email_from': self.env.company.email or 'noreply@example.com', + } + + try: + mail = self.env['mail.mail'].sudo().create(mail_values) + mail.send() + _logger.info(f"Sent accessibility assessment completion email to {office_emails}") + except Exception as e: + _logger.error(f"Failed to send assessment completion email: {e}") + + def _schedule_followup_activity(self, sale_order): + """Schedule a follow-up activity for the sales rep""" + self.ensure_one() + + if not self.sales_rep_id: + return + + type_labels = dict(self._fields['assessment_type'].selection) + type_label = type_labels.get(self.assessment_type, 'Accessibility') + + # Get the "To Do" activity type + activity_type = self.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False) + if not activity_type: + _logger.warning("Could not find 'To Do' activity type") + return + + # Schedule activity for tomorrow + due_date = fields.Date.today() + timedelta(days=1) + + try: + sale_order.activity_schedule( + activity_type_id=activity_type.id, + date_deadline=due_date, + user_id=self.sales_rep_id.id, + summary=f'Follow up on {type_label} Assessment', + note=f'Assessment {self.reference} for {self.client_name} has been completed. Please follow up with the client.', + ) + _logger.info(f"Scheduled follow-up activity for {self.sales_rep_id.name} on SO {sale_order.name}") + except Exception as e: + _logger.error(f"Failed to schedule follow-up activity: {e}") + + def _ensure_partner(self): + """Find or create a partner for the client""" + self.ensure_one() + Partner = self.env['res.partner'].sudo() + + # First, try to find existing partner by email + if self.client_email: + existing = Partner.search([('email', '=ilike', self.client_email)], limit=1) + if existing: + return existing + + # Create new partner + partner_vals = { + 'name': self.client_name, + 'email': self.client_email, + 'phone': self.client_phone, + 'street': self.client_address_street or self.client_address, + 'street2': self.client_unit or False, + 'city': self.client_address_city, + 'zip': self.client_address_postal, + 'customer_rank': 1, + } + + # Set province/state if provided + if self.client_address_province: + state = self.env['res.country.state'].sudo().search([ + ('code', '=ilike', self.client_address_province), + ('country_id.code', '=', 'CA'), + ], limit=1) + if state: + partner_vals['state_id'] = state.id + partner_vals['country_id'] = state.country_id.id + else: + # Default to Canada + canada = self.env.ref('base.ca', raise_if_not_found=False) + if canada: + partner_vals['country_id'] = canada.id + + partner = Partner.create(partner_vals) + _logger.info(f"Created partner {partner.name} from accessibility assessment {self.reference}") + return partner + + def _create_draft_sale_order(self, partner): + """Create a draft sale order from the assessment""" + self.ensure_one() + + SaleOrder = self.env['sale.order'].sudo() + + type_labels = dict(self._fields['assessment_type'].selection) + type_label = type_labels.get(self.assessment_type, 'Accessibility') + + so_vals = { + 'partner_id': partner.id, + 'user_id': self.sales_rep_id.id if self.sales_rep_id else self.env.user.id, + 'state': 'draft', + 'origin': f'Accessibility: {self.reference} ({type_label})', + 'x_fc_sale_type': 'direct_private', # Accessibility items typically private pay + } + + sale_order = SaleOrder.create(so_vals) + _logger.info(f"Created draft sale order {sale_order.name} from accessibility assessment {self.reference}") + + # Post assessment details to chatter + assessment_html = self._format_assessment_html_table() + sale_order.message_post( + body=Markup(assessment_html), + message_type='comment', + subtype_xmlid='mail.mt_note', + ) + + return sale_order + + def _format_assessment_html_table(self): + """Format assessment details as HTML for chatter""" + type_labels = dict(self._fields['assessment_type'].selection) + type_label = type_labels.get(self.assessment_type, 'Unknown') + + html = f''' + ' + return html + + def action_cancel(self): + """Cancel the assessment""" + self.ensure_one() + self.write({'state': 'cancelled'}) + + def action_reset_to_draft(self): + """Reset to draft state""" + self.ensure_one() + self.write({'state': 'draft'}) diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/models/adp_document.py b/fusion_authorizer_portal/fusion_authorizer_portal/models/adp_document.py new file mode 100644 index 0000000..9108fd8 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/models/adp_document.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +import base64 +import logging + +_logger = logging.getLogger(__name__) + + +class ADPDocument(models.Model): + _name = 'fusion.adp.document' + _description = 'ADP Application Document' + _order = 'upload_date desc, revision desc' + _rec_name = 'display_name' + + # Relationships + sale_order_id = fields.Many2one( + 'sale.order', + string='Sale Order', + ondelete='cascade', + index=True, + ) + + assessment_id = fields.Many2one( + 'fusion.assessment', + string='Assessment', + ondelete='cascade', + index=True, + ) + + # Document Type + document_type = fields.Selection([ + ('full_application', 'Full ADP Application (14 pages)'), + ('pages_11_12', 'Pages 11 & 12 (Signature Pages)'), + ('page_11', 'Page 11 Only (Authorizer Signature)'), + ('page_12', 'Page 12 Only (Client Signature)'), + ('submitted_final', 'Final Submitted Application'), + ('assessment_report', 'Assessment Report'), + ('assessment_signed', 'Signed Pages from Assessment'), + ('other', 'Other Document'), + ], string='Document Type', required=True, default='full_application') + + # File Data + file = fields.Binary( + string='File', + required=True, + attachment=True, + ) + + filename = fields.Char( + string='Filename', + required=True, + ) + + file_size = fields.Integer( + string='File Size (bytes)', + compute='_compute_file_size', + store=True, + ) + + mimetype = fields.Char( + string='MIME Type', + default='application/pdf', + ) + + # Revision Tracking + revision = fields.Integer( + string='Revision', + default=1, + readonly=True, + ) + + revision_note = fields.Text( + string='Revision Note', + help='Notes about what changed in this revision', + ) + + is_current = fields.Boolean( + string='Is Current Version', + default=True, + index=True, + ) + + # Upload Information + uploaded_by = fields.Many2one( + 'res.users', + string='Uploaded By', + default=lambda self: self.env.user, + readonly=True, + ) + + upload_date = fields.Datetime( + string='Upload Date', + default=fields.Datetime.now, + readonly=True, + ) + + source = fields.Selection([ + ('authorizer', 'Authorizer Portal'), + ('sales_rep', 'Sales Rep Portal'), + ('internal', 'Internal User'), + ('assessment', 'Assessment Form'), + ], string='Source', default='internal') + + # Display + display_name = fields.Char( + string='Display Name', + compute='_compute_display_name', + store=True, + ) + + @api.depends('file') + def _compute_file_size(self): + for doc in self: + if doc.file: + doc.file_size = len(base64.b64decode(doc.file)) + else: + doc.file_size = 0 + + @api.depends('document_type', 'filename', 'revision') + def _compute_display_name(self): + type_labels = dict(self._fields['document_type'].selection) + for doc in self: + type_label = type_labels.get(doc.document_type, doc.document_type) + doc.display_name = f"{type_label} - v{doc.revision} ({doc.filename or 'No file'})" + + @api.model_create_multi + def create(self, vals_list): + """Override create to handle revision numbering""" + for vals in vals_list: + # Find existing documents of the same type for the same order/assessment + domain = [('document_type', '=', vals.get('document_type'))] + + if vals.get('sale_order_id'): + domain.append(('sale_order_id', '=', vals.get('sale_order_id'))) + if vals.get('assessment_id'): + domain.append(('assessment_id', '=', vals.get('assessment_id'))) + + existing = self.search(domain, order='revision desc', limit=1) + + if existing: + # Mark existing as not current and increment revision + existing.is_current = False + vals['revision'] = existing.revision + 1 + else: + vals['revision'] = 1 + + vals['is_current'] = True + + return super().create(vals_list) + + def action_download(self): + """Download the document""" + self.ensure_one() + return { + 'type': 'ir.actions.act_url', + 'url': f'/web/content/{self._name}/{self.id}/file/{self.filename}?download=true', + 'target': 'self', + } + + def get_document_url(self): + """Get the download URL for portal access""" + self.ensure_one() + return f'/my/authorizer/document/{self.id}/download' + + @api.model + def get_documents_for_order(self, sale_order_id, document_type=None, current_only=True): + """Get documents for a sale order, optionally filtered by type""" + domain = [('sale_order_id', '=', sale_order_id)] + if document_type: + domain.append(('document_type', '=', document_type)) + if current_only: + domain.append(('is_current', '=', True)) + return self.search(domain, order='document_type, revision desc') + + @api.model + def get_revision_history(self, sale_order_id, document_type): + """Get all revisions of a specific document type""" + return self.search([ + ('sale_order_id', '=', sale_order_id), + ('document_type', '=', document_type), + ], order='revision desc') diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/models/assessment.py b/fusion_authorizer_portal/fusion_authorizer_portal/models/assessment.py new file mode 100644 index 0000000..be76e66 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/models/assessment.py @@ -0,0 +1,1633 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError +from markupsafe import Markup +from datetime import datetime, timedelta +import base64 +import json +import logging + +_logger = logging.getLogger(__name__) + + +class FusionAssessment(models.Model): + _name = 'fusion.assessment' + _description = 'ADP Assessment' + _inherit = ['mail.thread', 'mail.activity.mixin', 'fusion.email.builder.mixin'] + _order = 'assessment_date desc, id desc' + _rec_name = 'display_name' + + # Status + state = fields.Selection([ + ('draft', 'In Progress'), + ('pending_signature', 'Pending Signatures'), + ('completed', 'Completed'), + ('cancelled', 'Cancelled'), + ], string='Status', default='draft', tracking=True, index=True) + + display_name = fields.Char( + string='Display Name', + compute='_compute_display_name', + store=True, + ) + + # Reference + reference = fields.Char( + string='Reference', + readonly=True, + copy=False, + default=lambda self: _('New'), + ) + + # ===== EXPRESS FORM: EQUIPMENT TYPE ===== + equipment_type = fields.Selection([ + ('rollator', 'Rollator'), + ('wheelchair', 'Wheelchair'), + ('powerchair', 'Powerchair'), + ], string='Equipment Type', tracking=True, index=True) + + # Rollator Types + rollator_type = fields.Selection([ + ('type_1', 'Type 1'), + ('type_2', 'Type 2'), + ('type_3', 'Type 3'), + ], string='Rollator Type') + + # Wheelchair Types (Adult Manual) + wheelchair_type = fields.Selection([ + ('type_1', 'Type 1 - Adult Manual Standard Wheelchair'), + ('type_2', 'Type 2 - Adult Manual Lightweight Wheelchair'), + ('type_3', 'Type 3 - Adult Manual Ultra Lightweight Wheelchair'), + ('type_4', 'Type 4 - Adult Manual Tilt Wheelchair'), + ('type_5', 'Type 5 - Adult Manual Dynamic Tilt Wheelchair'), + ], string='Wheelchair Type') + + # Powerchair Types + powerchair_type = fields.Selection([ + ('type_1', 'Adult Power Base Type 1'), + ('type_2', 'Adult Power Base Type 2'), + ('type_3', 'Adult Power Base Type 3'), + ], string='Powerchair Type') + + # ===== EXPRESS FORM: ROLLATOR MEASUREMENTS ===== + rollator_handle_height = fields.Float(string='Handle Height (inches)', digits=(10, 2)) + rollator_seat_height = fields.Float(string='Seat Height (inches)', digits=(10, 2)) + + # ===== EXPRESS FORM: ROLLATOR ADDONS ===== + rollator_addons = fields.Text( + string='Rollator Addons', + help='Comma-separated list of selected rollator addons', + ) + + # ===== EXPRESS FORM: WHEELCHAIR/POWERCHAIR MEASUREMENTS ===== + legrest_length = fields.Float(string='Legrest Length (inches)', digits=(10, 2)) + cane_height = fields.Float(string='Cane Height (inches)', digits=(10, 2), help='Ground to Canes') + + # ===== EXPRESS FORM: WHEELCHAIR OPTIONS ===== + frame_options = fields.Text( + string='Frame Options', + help='Comma-separated list: Recliner Option, Dynamic Tilt Frame, Titanium Frame', + ) + + wheel_options = fields.Text( + string='Wheel Options/Addons', + help='Comma-separated list of wheel options', + ) + + legrest_options = fields.Text( + string='Legrest Accessories', + help='Comma-separated list of legrest accessories', + ) + + additional_adp_options = fields.Text( + string='Additional ADP Funded Options', + help='Comma-separated list of additional ADP options', + ) + + seatbelt_type = fields.Selection([ + ('standard', 'Standard Belt - No Padding'), + ('padded', 'Padded Belt - Positioning Belts (Modular)'), + ('4point', '4 Point Seat Belt - Positioning Belts (Modular)'), + ('chest_harness', 'Chest Harness - Positioning Belts (Modular)'), + ('additional_pads', 'Additional Pads - Positioning Belts (Custom)'), + ], string='Seat Belt Type') + + # ===== EXPRESS FORM: POWERCHAIR OPTIONS ===== + powerchair_options = fields.Text( + string='Powerchair Additional Options', + help='Comma-separated list of powerchair additional ADP options', + ) + + specialty_controls = fields.Text( + string='Specialty Components', + help='Comma-separated list of specialty controls (rationale required)', + ) + + # ===== EXPRESS FORM: ADDITIONAL INFO ===== + additional_customization = fields.Text( + string='Additional Information/Customization', + help='Free-form notes for customization requirements', + ) + + cushion_info = fields.Char( + string='Cushion', + help='Cushion details for wheelchair/powerchair', + ) + + backrest_info = fields.Char( + string='Backrest', + help='Backrest details for wheelchair/powerchair', + ) + + # ===== EXPRESS FORM: KEY DATES (map to SO x_fc_ fields) ===== + assessment_start_date = fields.Date( + string='Assessment Start Date', + tracking=True, + ) + assessment_end_date = fields.Date( + string='Assessment End Date', + tracking=True, + ) + claim_authorization_date = fields.Date( + string='Claim Authorization Date', + tracking=True, + ) + + # ===== EXPRESS FORM: CLIENT TYPE ===== + client_type = fields.Selection([ + ('reg', 'REG - Regular ADP'), + ('ods', 'ODS - ODSP'), + ('acs', 'ACS - ACSD'), + ('owp', 'OWP - Ontario Works'), + ], string='Client Type', default='reg', + help='REG = ADP only, ODS/ACS/OWP = ADP + ODSP') + + # ===== EXPRESS FORM: REASON FOR APPLICATION ===== + reason_for_application = fields.Selection([ + ('first_access', 'First Time Access - NO previous ADP'), + ('additions', 'Additions'), + ('mod_non_adp', 'Modification/Upgrade - Original NOT through ADP'), + ('mod_adp', 'Modification/Upgrade - Original through ADP'), + ('replace_status', 'Replacement - Change in Status'), + ('replace_size', 'Replacement - Change in Body Size'), + ('replace_worn', 'Replacement - Worn out (past useful life)'), + ('replace_lost', 'Replacement - Lost'), + ('replace_stolen', 'Replacement - Stolen'), + ('replace_damaged', 'Replacement - Damaged beyond repair'), + ('replace_no_longer_meets', 'Replacement - No longer meets needs'), + ('growth', 'Growth/Change in condition'), + ], string='Reason for Application') + + previous_funding_date = fields.Date( + string='Previous Funding Date', + help='Date of previous ADP funding (for replacements)', + ) + + # ===== EXPRESS FORM: CLIENT BIOGRAPHIC (enhanced) ===== + client_middle_name = fields.Char(string='Middle Name') + client_health_card_version = fields.Char(string='Health Card Version', help='Version code on health card') + + # ===== CLIENT INFORMATION ===== + client_name = fields.Char( + string='Client Name', + required=True, + tracking=True, + ) + client_first_name = fields.Char(string='First Name') + client_last_name = fields.Char(string='Last Name') + + client_address = fields.Text(string='Address') + client_street = fields.Char(string='Street') + client_unit = fields.Char(string='Unit/Apt/Suite') + client_city = fields.Char(string='City') + client_state = fields.Char(string='Province/State', default='Ontario') + client_postal_code = fields.Char(string='Postal Code') + client_country_id = fields.Many2one('res.country', string='Country', default=lambda self: self.env.ref('base.ca', raise_if_not_found=False)) + + client_phone = fields.Char(string='Phone') + client_mobile = fields.Char(string='Mobile') + client_email = fields.Char(string='Email') + + client_dob = fields.Date(string='Date of Birth') + client_health_card = fields.Char(string='Health Card Number') + + # Link to existing partner or create new + partner_id = fields.Many2one( + 'res.partner', + string='Client Partner', + help='Link to existing customer record', + ) + create_new_partner = fields.Boolean( + string='Create New Client', + default=True, + help='If checked, a new customer record will be created on completion', + ) + + # Client References (for searchability) + client_reference_1 = fields.Char(string='Client Reference 1') + client_reference_2 = fields.Char(string='Client Reference 2') + + # ===== ASSESSMENT PARTICIPANTS ===== + sales_rep_id = fields.Many2one( + 'res.users', + string='Sales Rep', + default=lambda self: self.env.user, + tracking=True, + ) + + authorizer_id = fields.Many2one( + 'res.partner', + string='Authorizer/OT', + tracking=True, + help='The Occupational Therapist or Authorizer conducting the assessment', + ) + + # ===== ASSESSMENT DATE/LOCATION ===== + assessment_date = fields.Datetime( + string='Assessment Date', + default=fields.Datetime.now, + required=True, + tracking=True, + ) + + assessment_location = fields.Selection([ + ('home', 'Client Home'), + ('clinic', 'Clinic/Office'), + ('hospital', 'Hospital'), + ('ltc', 'Long Term Care'), + ('other', 'Other'), + ], string='Location Type', default='home') + + assessment_location_notes = fields.Text(string='Location Notes') + + # ===== WHEELCHAIR SPECIFICATIONS ===== + # Measurements (in inches) + seat_width = fields.Float(string='Seat Width (inches)', digits=(10, 2)) + seat_depth = fields.Float(string='Seat Depth (inches)', digits=(10, 2)) + seat_to_floor_height = fields.Float(string='Seat to Floor Height (inches)', digits=(10, 2)) + back_height = fields.Float(string='Back Height (inches)', digits=(10, 2)) + armrest_height = fields.Float(string='Armrest Height (inches)', digits=(10, 2)) + footrest_length = fields.Float(string='Footrest Length (inches)', digits=(10, 2)) + + # Additional Measurements + overall_width = fields.Float(string='Overall Width (inches)', digits=(10, 2)) + overall_length = fields.Float(string='Overall Length (inches)', digits=(10, 2)) + overall_height = fields.Float(string='Overall Height (inches)', digits=(10, 2)) + seat_angle = fields.Float(string='Seat Angle (degrees)', digits=(10, 1)) + back_angle = fields.Float(string='Back Angle (degrees)', digits=(10, 1)) + + # Client Measurements + client_weight = fields.Float(string='Client Weight (lbs)', digits=(10, 1)) + client_height = fields.Float(string='Client Height (inches)', digits=(10, 1)) + + # ===== PRODUCT TYPES ===== + cushion_type = fields.Selection([ + ('foam', 'Foam'), + ('gel', 'Gel'), + ('air', 'Air'), + ('hybrid', 'Hybrid'), + ('custom', 'Custom Molded'), + ('other', 'Other'), + ], string='Cushion Type') + cushion_notes = fields.Text(string='Cushion Notes') + + backrest_type = fields.Selection([ + ('standard', 'Standard'), + ('adjustable', 'Adjustable Tension'), + ('solid', 'Solid Back'), + ('contoured', 'Contoured'), + ('custom', 'Custom'), + ('other', 'Other'), + ], string='Backrest Type') + backrest_notes = fields.Text(string='Backrest Notes') + + frame_type = fields.Selection([ + ('folding', 'Folding'), + ('rigid', 'Rigid'), + ('tilt', 'Tilt-in-Space'), + ('reclining', 'Reclining'), + ('standing', 'Standing'), + ('power', 'Power'), + ('other', 'Other'), + ], string='Frame Type') + frame_notes = fields.Text(string='Frame Notes') + + wheel_type = fields.Selection([ + ('pneumatic', 'Pneumatic'), + ('solid', 'Solid'), + ('flat_free', 'Flat-Free'), + ('mag', 'Mag Wheels'), + ('spoke', 'Spoke Wheels'), + ('other', 'Other'), + ], string='Wheel Type') + wheel_notes = fields.Text(string='Wheel Notes') + + # ===== ACCESSIBILITY/MOBILITY NEEDS ===== + mobility_notes = fields.Text( + string='Mobility Needs', + help='Document the client mobility needs and challenges', + ) + + accessibility_notes = fields.Text( + string='Accessibility Notes', + help='Document accessibility requirements and home environment', + ) + + special_requirements = fields.Text( + string='Special Requirements', + help='Any special requirements or customizations needed', + ) + + diagnosis = fields.Text( + string='Diagnosis/Condition', + help='Relevant medical diagnosis or conditions', + ) + + # ===== PAGE 11: CONSENT & DECLARATION ===== + consent_signed_by = fields.Selection([ + ('applicant', 'Applicant'), + ('agent', 'Agent'), + ], string='Consent Signed By', default='applicant', + help='Who signed the ADP consent declaration') + + consent_declaration_accepted = fields.Boolean( + string='Consent Declaration Accepted', + help='Whether the consent declaration checkbox was checked', + ) + consent_date = fields.Date( + string='Consent Date', + help='Date the consent was signed', + ) + + # Agent info (only if consent_signed_by == 'agent') + agent_relationship = fields.Selection([ + ('spouse', 'Spouse'), + ('parent', 'Parent(s)'), + ('child', 'Child'), + ('power_of_attorney', 'Power of Attorney'), + ('public_guardian', 'Public Guardian/Trustee'), + ], string='Agent Relationship') + 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_unit = fields.Char(string='Agent Unit') + agent_street_number = fields.Char(string='Agent Street Number') + agent_street_name = 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') + agent_home_phone = fields.Char(string='Agent Home Phone') + agent_business_phone = fields.Char(string='Agent Business Phone') + agent_phone_ext = fields.Char(string='Agent Phone Ext') + + # Generated filled PDF from template engine + signed_page_11_pdf = fields.Binary(string='Signed Page 11 PDF') + signed_page_11_pdf_filename = fields.Char(string='Signed Page 11 Filename') + + # ===== SIGNATURES ===== + signature_page_11 = fields.Binary( + string='Page 11 Signature (Authorizer)', + help='Digital signature for ADP Application Page 11', + ) + signature_page_11_name = fields.Char(string='Page 11 Signer Name') + signature_page_11_date = fields.Datetime(string='Page 11 Signed Date') + + signature_page_12 = fields.Binary( + string='Page 12 Signature (Client)', + help='Digital signature for ADP Application Page 12', + ) + signature_page_12_name = fields.Char(string='Page 12 Signer Name') + signature_page_12_date = fields.Datetime(string='Page 12 Signed Date') + + signatures_complete = fields.Boolean( + string='Signatures Complete', + compute='_compute_signatures_complete', + store=True, + ) + + # ===== RELATIONSHIPS ===== + document_ids = fields.One2many( + 'fusion.adp.document', + 'assessment_id', + string='Documents', + ) + + comment_ids = fields.One2many( + 'fusion.authorizer.comment', + 'assessment_id', + string='Comments', + ) + + sale_order_id = fields.Many2one( + 'sale.order', + string='Created Sale Order', + readonly=True, + copy=False, + ) + + # ===== COMPUTED FIELDS ===== + document_count = fields.Integer( + string='Document Count', + compute='_compute_document_count', + ) + + @api.depends('client_name', 'reference', 'equipment_type') + def _compute_display_name(self): + equipment_labels = {'rollator': 'Rollator', 'wheelchair': 'Wheelchair', 'powerchair': 'Powerchair'} + for assessment in self: + if assessment.reference and assessment.reference != 'New': + equip = equipment_labels.get(assessment.equipment_type, '') + equip_suffix = f' ({equip})' if equip else '' + assessment.display_name = f"{assessment.reference} - {assessment.client_name or 'Unknown'}{equip_suffix}" + else: + assessment.display_name = assessment.client_name or _('New Assessment') + + @api.depends('signature_page_11', 'signature_page_12') + def _compute_signatures_complete(self): + for assessment in self: + assessment.signatures_complete = bool(assessment.signature_page_11 and assessment.signature_page_12) + + @api.depends('document_ids') + def _compute_document_count(self): + for assessment in self: + assessment.document_count = len(assessment.document_ids) + + @api.model_create_multi + def create(self, vals_list): + """Override create to generate reference number""" + for vals in vals_list: + if vals.get('reference', 'New') == 'New': + vals['reference'] = self.env['ir.sequence'].next_by_code('fusion.assessment') or 'New' + return super().create(vals_list) + + def action_mark_pending_signature(self): + """Move to pending signature state""" + self.ensure_one() + self.state = 'pending_signature' + return True + + def action_complete(self): + """Complete the assessment and create draft sale order""" + self.ensure_one() + + if not self.signatures_complete: + raise UserError(_('Please ensure both Page 11 and Page 12 signatures are captured before completing.')) + + # Create or link partner + partner = self._ensure_partner() + + # Create draft sale order + sale_order = self._create_draft_sale_order(partner) + + # Generate signed documents + self._generate_signed_documents() + + # Update state + self.write({ + 'state': 'completed', + 'sale_order_id': sale_order.id, + 'partner_id': partner.id, + }) + + # Send notifications + self._send_completion_notifications() + + return { + 'type': 'ir.actions.act_window', + 'name': _('Created Sale Order'), + 'res_model': 'sale.order', + 'res_id': sale_order.id, + 'view_mode': 'form', + 'target': 'current', + } + + def action_complete_express(self): + """Complete express assessment and create draft sale order (no signatures required)""" + self.ensure_one() + + # Validate required fields + if not self.equipment_type: + raise UserError(_('Please select an equipment type.')) + if not self.authorizer_id: + raise UserError(_('Please select an authorizer.')) + if not self.client_name: + raise UserError(_('Please enter the client name.')) + + # Create or link partner + partner = self._ensure_partner() + + # Create draft sale order + sale_order = self._create_draft_sale_order(partner) + + # Update state + self.write({ + 'state': 'completed', + 'sale_order_id': sale_order.id, + 'partner_id': partner.id, + }) + + _logger.info(f"Completed express assessment {self.reference}, created SO {sale_order.name}") + + return sale_order + + def action_cancel(self): + """Cancel the assessment""" + self.ensure_one() + self.state = 'cancelled' + return True + + def action_reset_draft(self): + """Reset to draft state""" + self.ensure_one() + self.state = 'draft' + return True + + def _ensure_partner(self): + """Ensure a partner exists for the client, create if needed""" + self.ensure_one() + + if self.partner_id: + return self.partner_id + + if not self.create_new_partner: + raise UserError(_('Please select an existing client or check "Create New Client".')) + + # Build address + address_parts = [] + if self.client_street: + address_parts.append(self.client_street) + if self.client_city: + address_parts.append(self.client_city) + if self.client_state: + address_parts.append(self.client_state) + if self.client_postal_code: + address_parts.append(self.client_postal_code) + + partner_vals = { + 'name': self.client_name, + 'street': self.client_street or '', + 'street2': self.client_unit or False, + 'city': self.client_city or '', + 'zip': self.client_postal_code or '', + 'phone': self.client_phone or '', + 'email': self.client_email or '', + 'customer_rank': 1, + } + + if self.client_country_id: + partner_vals['country_id'] = self.client_country_id.id + + partner = self.env['res.partner'].sudo().create(partner_vals) + _logger.info(f"Created new partner {partner.name} (ID: {partner.id}) from assessment {self.reference}") + + return partner + + def _create_draft_sale_order(self, partner): + """Create a draft sale order from the assessment""" + self.ensure_one() + + SaleOrder = self.env['sale.order'].sudo() + CrmTag = self.env['crm.tag'].sudo() + + # Determine sale type based on client type + # REG = ADP only, ODS/ACS/OWP = ADP + ODSP + sale_type = 'adp' + if self.client_type in ['ods', 'acs', 'owp']: + sale_type = 'adp_odsp' + + # Determine the correct workflow status for the SO. + # The write() auto-transition (assessment_completed -> waiting_for_application) + # does NOT fire during create(), so we set the final target status directly. + if self.assessment_start_date and self.assessment_end_date: + # Both dates filled = assessment completed -> advance to waiting_for_application + target_status = 'waiting_for_application' + elif self.assessment_start_date: + # Only start date = assessment is scheduled + target_status = 'assessment_scheduled' + else: + target_status = 'quotation' + + # Prepare values + so_vals = { + 'partner_id': partner.id, + 'user_id': self.sales_rep_id.id if self.sales_rep_id else self.env.user.id, + 'state': 'draft', + 'origin': f'Assessment: {self.reference}', + 'x_fc_sale_type': sale_type, + # Issue 2 fix: Set assessment back-link for traceability + 'assessment_id': self.id, + # Set the correct workflow status directly + 'x_fc_adp_application_status': target_status, + } + + # Set authorizer if available + if self.authorizer_id: + so_vals['x_fc_authorizer_id'] = self.authorizer_id.id + + # Set client references if available + if self.client_reference_1: + so_vals['x_fc_client_ref_1'] = self.client_reference_1 + if self.client_reference_2: + so_vals['x_fc_client_ref_2'] = self.client_reference_2 + + # Map assessment dates to SO x_fc_ fields + if self.assessment_start_date: + so_vals['x_fc_assessment_start_date'] = self.assessment_start_date + if self.assessment_end_date: + so_vals['x_fc_assessment_end_date'] = self.assessment_end_date + if self.claim_authorization_date: + so_vals['x_fc_claim_authorization_date'] = self.claim_authorization_date + + # Map reason for application + if self.reason_for_application: + so_vals['x_fc_reason_for_application'] = self.reason_for_application + if self.previous_funding_date: + so_vals['x_fc_previous_funding_date'] = self.previous_funding_date + + # Map client type (assessment uses lowercase, sale.order uses uppercase) + if self.client_type: + client_type_map = { + 'reg': 'REG', + 'ods': 'ODS', + 'acs': 'ACS', + 'owp': 'OWP', + } + so_vals['x_fc_client_type'] = client_type_map.get(self.client_type, self.client_type.upper()) + + # ===================================================================== + # Issue 3 & 4 fix: Map Page 11 consent & signature tracking to SO + # ===================================================================== + if self.consent_signed_by: + # Map consent_signed_by to x_fc_page11_signer_type + if self.consent_signed_by == 'applicant': + so_vals['x_fc_page11_signer_type'] = 'client' + so_vals['x_fc_page11_signer_name'] = self.client_name or '' + elif self.consent_signed_by == 'agent': + # Map agent_relationship to signer_type + agent_type_map = { + 'spouse': 'spouse', + 'parent': 'parent', + 'child': 'legal_guardian', + 'power_of_attorney': 'poa', + 'public_guardian': 'public_trustee', + } + so_vals['x_fc_page11_signer_type'] = agent_type_map.get( + self.agent_relationship, 'legal_guardian' + ) + agent_name = f"{self.agent_first_name or ''} {self.agent_last_name or ''}".strip() + so_vals['x_fc_page11_signer_name'] = agent_name or self.client_name or '' + if self.agent_relationship: + relationship_labels = dict(self._fields['agent_relationship'].selection) + so_vals['x_fc_page11_signer_relationship'] = relationship_labels.get( + self.agent_relationship, self.agent_relationship + ) + + if self.consent_date: + so_vals['x_fc_page11_signed_date'] = self.consent_date + + # Build tags list + tag_ids = [] + + # Always add ADP tag + adp_tag = CrmTag.search([('name', 'ilike', 'ADP')], limit=1) + if adp_tag: + tag_ids.append(adp_tag.id) + + # Add equipment type tag based on selection + equipment_tag_name = None + if self.equipment_type == 'wheelchair' and self.wheelchair_type: + type_map = { + 'type_1': 'TYPE 1 WHEELCHAIR', + 'type_2': 'TYPE 2 WHEELCHAIR', + 'type_3': 'TYPE 3 WHEELCHAIR', + 'type_4': 'TYPE 4 WHEELCHAIR', + 'type_5': 'TYPE 5 WHEELCHAIR', + } + equipment_tag_name = type_map.get(self.wheelchair_type) + elif self.equipment_type == 'rollator' and self.rollator_type: + type_map = { + 'type_1': 'TYPE 1 ROLLATOR', + 'type_2': 'TYPE 2 ROLLATOR', + 'type_3': 'TYPE 3 ROLLATOR', + } + equipment_tag_name = type_map.get(self.rollator_type) + elif self.equipment_type == 'powerchair' and self.powerchair_type: + type_map = { + 'type_1': 'TYPE 1 POWERCHAIR', + 'type_2': 'TYPE 2 POWERCHAIR', + 'type_3': 'TYPE 3 POWERCHAIR', + } + equipment_tag_name = type_map.get(self.powerchair_type) + + # Find or create the equipment tag + if equipment_tag_name: + equipment_tag = CrmTag.search([('name', 'ilike', equipment_tag_name)], limit=1) + if not equipment_tag: + # Create the tag if it doesn't exist + equipment_tag = CrmTag.create({'name': equipment_tag_name}) + _logger.info(f"Created new CRM tag: {equipment_tag_name}") + tag_ids.append(equipment_tag.id) + + # Add tags to sale order values + if tag_ids: + so_vals['tag_ids'] = [(6, 0, tag_ids)] + + sale_order = SaleOrder.create(so_vals) + _logger.info(f"Created draft sale order {sale_order.name} from assessment {self.reference} " + f"with sale_type={sale_type}, status={target_status}") + + # ===================================================================== + # Issue 6 fix: Post workflow-consistent chatter messages + # ===================================================================== + # Post a workflow status message matching what backend wizards would generate + equipment_labels = { + 'rollator': 'Rollator', 'wheelchair': 'Wheelchair', 'powerchair': 'Powerchair', + } + equipment_label = equipment_labels.get(self.equipment_type, 'Equipment') + sales_rep_name = self.sales_rep_id.name if self.sales_rep_id else self.env.user.name + authorizer_name = self.authorizer_id.name if self.authorizer_id else 'N/A' + + workflow_msg = Markup( + '' + ) + sale_order.message_post( + body=workflow_msg, + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + + # Post assessment details to chatter as HTML table + assessment_html = self._format_assessment_html_table() + sale_order.message_post( + body=Markup(assessment_html), + message_type='comment', + subtype_xmlid='mail.mt_note', + ) + + # ===================================================================== + # Issue 4 fix: Post consent & agent details to chatter + # ===================================================================== + if self.consent_signed_by: + consent_label = 'Applicant' if self.consent_signed_by == 'applicant' else 'Agent' + consent_parts = [ + f'
  • Signed by: {consent_label}
  • ', + ] + if self.consent_date: + consent_parts.append(f'
  • Consent date: {self.consent_date}
  • ') + if self.consent_declaration_accepted: + consent_parts.append('
  • Declaration accepted: Yes
  • ') + + # Add agent details if signed by agent + if self.consent_signed_by == 'agent': + agent_name = f"{self.agent_first_name or ''} {self.agent_last_name or ''}".strip() + if agent_name: + consent_parts.append(f'
  • Agent name: {agent_name}
  • ') + if self.agent_relationship: + rel_labels = dict(self._fields['agent_relationship'].selection) + consent_parts.append( + f'
  • Relationship: ' + f'{rel_labels.get(self.agent_relationship, self.agent_relationship)}
  • ' + ) + agent_addr_parts = [] + if self.agent_unit: + agent_addr_parts.append(f'Unit {self.agent_unit}') + if self.agent_street_number: + agent_addr_parts.append(self.agent_street_number) + if self.agent_street_name: + agent_addr_parts.append(self.agent_street_name) + if self.agent_city: + agent_addr_parts.append(self.agent_city) + if self.agent_province: + agent_addr_parts.append(self.agent_province) + if self.agent_postal_code: + agent_addr_parts.append(self.agent_postal_code) + if agent_addr_parts: + consent_parts.append( + f'
  • Agent address: {", ".join(agent_addr_parts)}
  • ' + ) + phones = [] + if self.agent_home_phone: + phones.append(f'Home: {self.agent_home_phone}') + if self.agent_business_phone: + ext = f' ext {self.agent_phone_ext}' if self.agent_phone_ext else '' + phones.append(f'Business: {self.agent_business_phone}{ext}') + if phones: + consent_parts.append( + f'
  • Agent phone: {", ".join(phones)}
  • ' + ) + + consent_msg = Markup( + '' + ) + sale_order.message_post( + body=consent_msg, + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + + # Send email notification to sales person and authorizer + self._send_assessment_completed_email(sale_order) + + # Schedule follow-up activity for sales rep + self._schedule_followup_activity(sale_order) + + return sale_order + + def _schedule_followup_activity(self, sale_order): + """Schedule a follow-up activity for the sales rep""" + self.ensure_one() + + sales_rep = self.sales_rep_id or self.env.user + if not sales_rep: + return + + # Get the "To Do" activity type + activity_type = self.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False) + if not activity_type: + _logger.warning("Could not find 'To Do' activity type") + return + + # Get equipment type label + equipment_labels = { + 'wheelchair': 'Wheelchair', + 'rollator': 'Rollator', + 'powerchair': 'Powerchair', + } + equipment_label = equipment_labels.get(self.equipment_type, 'Equipment') + + # Schedule activity for tomorrow + due_date = fields.Date.today() + timedelta(days=1) + + try: + sale_order.activity_schedule( + activity_type_id=activity_type.id, + date_deadline=due_date, + user_id=sales_rep.id, + summary=f'Follow up on {equipment_label} Assessment', + note=f'Assessment {self.reference} for {self.client_name} has been completed. Please follow up with the client and authorizer.', + ) + _logger.info(f"Scheduled follow-up activity for {sales_rep.name} on SO {sale_order.name}") + except Exception as e: + _logger.error(f"Failed to schedule follow-up activity: {e}") + + def _send_assessment_completed_email(self, sale_order): + """Send email notification to sales person, authorizer, and office about completed assessment. + Includes the full assessment report so recipients can review without logging in.""" + self.ensure_one() + + if not self._email_is_enabled(): + return + + to_emails = [] + cc_emails = [] + if self.authorizer_id and self.authorizer_id.email: + to_emails.append(self.authorizer_id.email) + if self.sales_rep_id and self.sales_rep_id.email: + cc_emails.append(self.sales_rep_id.email) + company = self.env.company + office_partners = company.sudo().x_fc_office_notification_ids + cc_emails.extend([p.email for p in office_partners if p.email]) + if not to_emails and not cc_emails: + return + + sales_rep_name = self.sales_rep_id.name if self.sales_rep_id else 'The Sales Team' + assessment_date = self.assessment_end_date.strftime('%B %d, %Y') if self.assessment_end_date else 'Today' + equipment_labels = {'rollator': 'Rollator', 'wheelchair': 'Wheelchair', 'powerchair': 'Powerchair'} + equipment_label = equipment_labels.get(self.equipment_type, 'Equipment') + + # Build the detailed assessment report sections for the email + report_sections = self._build_assessment_email_sections(sale_order, equipment_label, assessment_date) + + email_body = self._email_build( + title='Assessment Completed', + summary=f'The ADP assessment for {self.client_name} has been completed ' + f'on {assessment_date}. A draft sales order has been created. ' + f'The full assessment report is included below.', + email_type='success', + sections=report_sections, + note='Next steps: Please submit the ADP application ' + '(including pages 11-12 signed by the client) so we can proceed with the claim submission.', + button_url=f'{sale_order.get_base_url()}/web#id={sale_order.id}&model=sale.order&view_type=form', + button_text='View Sale Order', + sender_name=sales_rep_name, + ) + + email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1]) + email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:]) + try: + self.env['mail.mail'].sudo().create({ + 'subject': f'Assessment Completed - {self.client_name} ({equipment_label}) - {sale_order.name}', + 'body_html': email_body, + 'email_to': email_to, 'email_cc': email_cc, + 'model': 'sale.order', 'res_id': sale_order.id, + 'auto_delete': True, + }).send() + chatter_body = Markup( + '' + ) + sale_order.message_post(body=chatter_body, message_type='notification', subtype_xmlid='mail.mt_note') + _logger.info(f"Sent assessment completed email for {self.reference}") + except Exception as e: + _logger.error(f"Failed to send assessment completed email: {e}") + + def _build_assessment_email_sections(self, sale_order, equipment_label, assessment_date): + """Build comprehensive email sections with all assessment details. + Returns a list of (heading, rows) tuples for _email_build().""" + self.ensure_one() + + sections = [] + + # --- Section 1: Overview --- + overview_rows = [ + ('Reference', self.reference), + ('Sales Order', sale_order.name), + ('Client', self.client_name), + ('Equipment', equipment_label), + ('Assessment Date', assessment_date), + ('Authorizer/OT', self.authorizer_id.name if self.authorizer_id else None), + ('Sales Rep', self.sales_rep_id.name if self.sales_rep_id else None), + ] + if self.client_type: + ct_labels = { + 'reg': 'REG - Regular ADP', + 'ods': 'ODS - ODSP', + 'acs': 'ACS - ACSD', + 'owp': 'OWP - Ontario Works', + } + overview_rows.append(('Client Type', ct_labels.get(self.client_type, self.client_type))) + if self.reason_for_application: + reason_labels = dict(self._fields['reason_for_application'].selection) + overview_rows.append(('Reason for Application', reason_labels.get( + self.reason_for_application, self.reason_for_application))) + if self.previous_funding_date: + overview_rows.append(('Previous Funding Date', str(self.previous_funding_date))) + sections.append(('Assessment Overview', overview_rows)) + + # --- Section 2: Client Information --- + client_rows = [ + ('Name', self.client_name), + ] + if self.client_first_name or self.client_last_name: + full = f"{self.client_first_name or ''} {self.client_middle_name or ''} {self.client_last_name or ''}".strip() + client_rows.append(('Full Name', full)) + if self.client_weight: + client_rows.append(('Weight', f'{int(self.client_weight)} lbs')) + if self.client_dob: + client_rows.append(('Date of Birth', str(self.client_dob))) + if self.client_health_card: + version = f' ({self.client_health_card_version})' if self.client_health_card_version else '' + client_rows.append(('Health Card', f'{self.client_health_card}{version}')) + # Address + addr_parts = [p for p in [ + self.client_street, self.client_unit, self.client_city, + self.client_state, self.client_postal_code, + ] if p] + if addr_parts: + client_rows.append(('Address', ', '.join(addr_parts))) + if self.client_phone: + client_rows.append(('Phone', self.client_phone)) + if self.client_mobile: + client_rows.append(('Mobile', self.client_mobile)) + if self.client_email: + client_rows.append(('Email', self.client_email)) + sections.append(('Client Information', client_rows)) + + # --- Section 3: Equipment Type & Subtype --- + equip_rows = [('Equipment Type', equipment_label)] + + if self.equipment_type == 'rollator': + if self.rollator_type: + type_labels = dict(self._fields['rollator_type'].selection) + equip_rows.append(('Rollator Type', type_labels.get(self.rollator_type, self.rollator_type))) + elif self.equipment_type == 'wheelchair': + if self.wheelchair_type: + type_labels = dict(self._fields['wheelchair_type'].selection) + equip_rows.append(('Wheelchair Type', type_labels.get(self.wheelchair_type, self.wheelchair_type))) + elif self.equipment_type == 'powerchair': + if self.powerchair_type: + type_labels = dict(self._fields['powerchair_type'].selection) + equip_rows.append(('Powerchair Type', type_labels.get(self.powerchair_type, self.powerchair_type))) + + sections.append(('Equipment Selection', equip_rows)) + + # --- Section 4: Measurements --- + meas_rows = [] + if self.equipment_type == 'rollator': + if self.rollator_handle_height: + meas_rows.append(('Handle Height', f'{self.rollator_handle_height}"')) + if self.rollator_seat_height: + meas_rows.append(('Seat Height', f'{self.rollator_seat_height}"')) + else: + # Wheelchair / Powerchair measurements + if self.seat_width: + meas_rows.append(('Seat Width', f'{self.seat_width}"')) + if self.seat_depth: + meas_rows.append(('Seat Depth', f'{self.seat_depth}"')) + if self.legrest_length: + meas_rows.append(('Legrest Length', f'{self.legrest_length}"')) + if self.seat_to_floor_height: + sfh_label = f'{self.seat_to_floor_height}" (Including Cushion)' if self.equipment_type == 'wheelchair' else f'{self.seat_to_floor_height}"' + meas_rows.append(('Seat to Floor Height', sfh_label)) + if self.cane_height: + meas_rows.append(('Cane Height', f'{self.cane_height}" (Ground to Canes)')) + if self.back_height: + meas_rows.append(('Back Height', f'{self.back_height}"')) + if self.armrest_height: + meas_rows.append(('Armrest Height', f'{self.armrest_height}"')) + if self.footrest_length: + meas_rows.append(('Footrest Length', f'{self.footrest_length}"')) + if meas_rows: + sections.append(('Measurements', meas_rows)) + + # --- Section 5: Options & Addons --- + options_rows = [] + if self.equipment_type == 'rollator' and self.rollator_addons: + for addon in self.rollator_addons.split(','): + addon = addon.strip() + if addon: + options_rows.append((addon, 'Yes')) + elif self.equipment_type == 'wheelchair': + if self.frame_options: + for opt in self.frame_options.split(','): + opt = opt.strip() + if opt: + options_rows.append((f'Frame: {opt}', 'Yes')) + if self.wheel_options: + for opt in self.wheel_options.split(','): + opt = opt.strip() + if opt: + options_rows.append((f'Wheel: {opt}', 'Yes')) + if self.legrest_options: + for opt in self.legrest_options.split(','): + opt = opt.strip() + if opt: + options_rows.append((f'Legrest: {opt}', 'Yes')) + if self.additional_adp_options: + for opt in self.additional_adp_options.split(','): + opt = opt.strip() + if opt: + options_rows.append((f'ADP Option: {opt}', 'Yes')) + elif self.equipment_type == 'powerchair': + if self.powerchair_options: + for opt in self.powerchair_options.split(','): + opt = opt.strip() + if opt: + options_rows.append((f'Powerchair: {opt}', 'Yes')) + if self.specialty_controls: + for opt in self.specialty_controls.split(','): + opt = opt.strip() + if opt: + options_rows.append((f'Specialty: {opt}', 'Yes')) + + # Seatbelt (wheelchair & powerchair) + if self.seatbelt_type and self.equipment_type in ('wheelchair', 'powerchair'): + belt_labels = dict(self._fields['seatbelt_type'].selection) + options_rows.append(('Seat Belt', belt_labels.get(self.seatbelt_type, self.seatbelt_type))) + + # Cushion & Backrest + if self.cushion_info: + options_rows.append(('Cushion', self.cushion_info)) + if self.backrest_info: + options_rows.append(('Backrest', self.backrest_info)) + + if options_rows: + sections.append(('Options & Accessories', options_rows)) + + # --- Section 6: Additional Notes --- + notes_rows = [] + if self.additional_customization: + notes_rows.append(('Customization', self.additional_customization)) + if self.mobility_notes: + notes_rows.append(('Mobility Needs', self.mobility_notes)) + if self.accessibility_notes: + notes_rows.append(('Accessibility', self.accessibility_notes)) + if self.special_requirements: + notes_rows.append(('Special Requirements', self.special_requirements)) + if self.diagnosis: + notes_rows.append(('Diagnosis/Condition', self.diagnosis)) + if notes_rows: + sections.append(('Additional Notes', notes_rows)) + + # --- Section 7: Key Dates --- + date_rows = [] + if self.assessment_start_date: + date_rows.append(('Assessment Start', self.assessment_start_date.strftime('%B %d, %Y'))) + if self.assessment_end_date: + date_rows.append(('Assessment End', self.assessment_end_date.strftime('%B %d, %Y'))) + if self.claim_authorization_date: + date_rows.append(('Claim Authorization', self.claim_authorization_date.strftime('%B %d, %Y'))) + if date_rows: + sections.append(('Key Dates', date_rows)) + + # --- Section 8: Consent Information --- + if self.consent_signed_by: + consent_rows = [] + consent_label = 'Applicant' if self.consent_signed_by == 'applicant' else 'Agent' + consent_rows.append(('Signed By', consent_label)) + if self.consent_date: + consent_rows.append(('Consent Date', str(self.consent_date))) + if self.consent_declaration_accepted: + consent_rows.append(('Declaration Accepted', 'Yes')) + if self.consent_signed_by == 'agent': + agent_name = f"{self.agent_first_name or ''} {self.agent_last_name or ''}".strip() + if agent_name: + consent_rows.append(('Agent Name', agent_name)) + if self.agent_relationship: + rel_labels = dict(self._fields['agent_relationship'].selection) + consent_rows.append(('Relationship', rel_labels.get( + self.agent_relationship, self.agent_relationship))) + sections.append(('Consent & Declaration (Page 11)', consent_rows)) + + return sections + + def _format_assessment_html_table(self): + """Format assessment data as HTML table for chatter""" + self.ensure_one() + + # Get equipment type display name + equipment_labels = { + 'rollator': 'Rollator', + 'wheelchair': 'Wheelchair', + 'powerchair': 'Powerchair', + } + equipment_display = equipment_labels.get(self.equipment_type, 'Unknown') + + # Start building HTML + html = f''' +

    ADP Assessment - {equipment_display}

    + + + + + + ''' + + row_num = 0 + def add_row(label, value, is_header=False): + nonlocal html, row_num + if is_header: + html += f''' + + + + ''' + else: + bg = '#f9f9f9' if row_num % 2 == 0 else '#ffffff' + html += f''' + + + + + ''' + row_num += 1 + + # Client Type + if self.client_type: + client_type_labels = { + 'reg': 'REG - Regular ADP', + 'ods': 'ODS - ODSP', + 'acs': 'ACS - ACSD', + 'owp': 'OWP - Ontario Works', + } + add_row('Client Type', client_type_labels.get(self.client_type, self.client_type)) + + # Equipment Type + add_row('Equipment Type', equipment_display) + + # Client Weight - shown prominently at the top + if self.client_weight: + add_row('Client Weight', f'{int(self.client_weight)} lbs') + + # Type-specific info + if self.equipment_type == 'rollator': + if self.rollator_type: + type_labels = dict(self._fields['rollator_type'].selection) + add_row('Rollator Type', type_labels.get(self.rollator_type, self.rollator_type)) + + add_row('Measurements', '', is_header=True) + if self.rollator_handle_height: + add_row('Handle Height', f'{self.rollator_handle_height}"') + if self.rollator_seat_height: + add_row('Seat Height', f'{self.rollator_seat_height}"') + + if self.rollator_addons: + add_row('Addons', '', is_header=True) + for addon in self.rollator_addons.split(','): + addon = addon.strip() + if addon: + add_row(addon, 'Yes') + + elif self.equipment_type == 'wheelchair': + if self.wheelchair_type: + type_labels = dict(self._fields['wheelchair_type'].selection) + add_row('Wheelchair Type', type_labels.get(self.wheelchair_type, self.wheelchair_type)) + + add_row('Measurements', '', is_header=True) + if self.seat_width: + add_row('Seat Width', f'{self.seat_width}"') + if self.seat_depth: + add_row('Seat Depth', f'{self.seat_depth}"') + if self.legrest_length: + add_row('Legrest Length', f'{self.legrest_length}"') + if self.seat_to_floor_height: + add_row('Seat to Floor Height', f'{self.seat_to_floor_height}" (Including Cushion)') + if self.cane_height: + add_row('Cane Height', f'{self.cane_height}" (Ground to Canes)') + if self.back_height: + add_row('Back Height', f'{self.back_height}"') + + if self.frame_options: + add_row('Frame Options - ADP Funded', '', is_header=True) + for opt in self.frame_options.split(','): + opt = opt.strip() + if opt: + add_row(opt, 'Yes') + + if self.wheel_options: + add_row('Wheel Options/Addons - ADP Funded', '', is_header=True) + for opt in self.wheel_options.split(','): + opt = opt.strip() + if opt: + add_row(opt, 'Yes') + + if self.legrest_options: + add_row('Legrest Accessories - ADP Funded', '', is_header=True) + for opt in self.legrest_options.split(','): + opt = opt.strip() + if opt: + add_row(opt, 'Yes') + + if self.additional_adp_options: + add_row('Additional ADP Funded Options', '', is_header=True) + for opt in self.additional_adp_options.split(','): + opt = opt.strip() + if opt: + add_row(opt, 'Yes') + + if self.seatbelt_type: + belt_labels = dict(self._fields['seatbelt_type'].selection) + add_row('Seat Belt Type', belt_labels.get(self.seatbelt_type, self.seatbelt_type)) + + elif self.equipment_type == 'powerchair': + if self.powerchair_type: + type_labels = dict(self._fields['powerchair_type'].selection) + add_row('Powerchair Type', type_labels.get(self.powerchair_type, self.powerchair_type)) + + add_row('Measurements', '', is_header=True) + if self.seat_width: + add_row('Seat Width', f'{self.seat_width}"') + if self.seat_depth: + add_row('Seat Depth', f'{self.seat_depth}"') + if self.legrest_length: + add_row('Legrest Length', f'{self.legrest_length}"') + if self.seat_to_floor_height: + add_row('Seat to Floor Height', f'{self.seat_to_floor_height}"') + if self.cane_height: + add_row('Cane Height', f'{self.cane_height}" (Ground to Canes)') + if self.back_height: + add_row('Back Height', f'{self.back_height}"') + if self.armrest_height: + add_row('Armrest Height', f'{self.armrest_height}"') + if self.footrest_length: + add_row('Footrest Length', f'{self.footrest_length}"') + + if self.seatbelt_type: + belt_labels = dict(self._fields['seatbelt_type'].selection) + add_row('Seat Belt Type', belt_labels.get(self.seatbelt_type, self.seatbelt_type)) + + if self.powerchair_options: + add_row('Powerchair - Additional ADP Funded Options', '', is_header=True) + for opt in self.powerchair_options.split(','): + opt = opt.strip() + if opt: + add_row(opt, 'Yes') + + if self.specialty_controls: + add_row('Specialty Components (Rationale Required)', '', is_header=True) + for opt in self.specialty_controls.split(','): + opt = opt.strip() + if opt: + add_row(opt, 'Yes') + + # Cushion & Backrest (wheelchair/powerchair) + if self.cushion_info: + add_row('Cushion', self.cushion_info) + if self.backrest_info: + add_row('Backrest', self.backrest_info) + + # Additional customization notes + if self.additional_customization: + add_row('Additional Information/Customization', self.additional_customization) + + # Client info summary + add_row('Client Information', '', is_header=True) + add_row('Client Name', self.client_name or '') + if self.client_weight: + add_row('Client Weight', f'{int(self.client_weight)} lbs') + if self.client_health_card: + version = f' ({self.client_health_card_version})' if self.client_health_card_version else '' + add_row('Health Card', f'{self.client_health_card}{version}') + if self.client_phone: + add_row('Phone', self.client_phone) + if self.client_email: + add_row('Email', self.client_email) + + # Dates + add_row('Key Dates', '', is_header=True) + if self.assessment_start_date: + add_row('Assessment Start Date', str(self.assessment_start_date)) + if self.assessment_end_date: + add_row('Assessment End Date', str(self.assessment_end_date)) + if self.claim_authorization_date: + add_row('Claim Authorization Date', str(self.claim_authorization_date)) + + if self.reason_for_application: + reason_labels = dict(self._fields['reason_for_application'].selection) + add_row('Reason for Application', reason_labels.get(self.reason_for_application, self.reason_for_application)) + + html += '
    FieldValue
    {label}
    {label}{value}
    ' + return html + + def _format_specifications_for_order(self): + """Format wheelchair specifications for the sale order notes (legacy)""" + self.ensure_one() + + lines = [ + f"=== Assessment Specifications ({self.reference}) ===", + "", + "MEASUREMENTS:", + ] + + if self.seat_width: + lines.append(f" Seat Width: {self.seat_width}\"") + if self.seat_depth: + lines.append(f" Seat Depth: {self.seat_depth}\"") + if self.seat_to_floor_height: + lines.append(f" Seat to Floor: {self.seat_to_floor_height}\"") + if self.back_height: + lines.append(f" Back Height: {self.back_height}\"") + if self.armrest_height: + lines.append(f" Armrest Height: {self.armrest_height}\"") + if self.footrest_length: + lines.append(f" Footrest Length: {self.footrest_length}\"") + + lines.append("") + lines.append("PRODUCT SELECTIONS:") + + if self.cushion_type: + lines.append(f" Cushion: {dict(self._fields['cushion_type'].selection).get(self.cushion_type, self.cushion_type)}") + if self.backrest_type: + lines.append(f" Backrest: {dict(self._fields['backrest_type'].selection).get(self.backrest_type, self.backrest_type)}") + if self.frame_type: + lines.append(f" Frame: {dict(self._fields['frame_type'].selection).get(self.frame_type, self.frame_type)}") + if self.wheel_type: + lines.append(f" Wheels: {dict(self._fields['wheel_type'].selection).get(self.wheel_type, self.wheel_type)}") + + if self.mobility_notes: + lines.append("") + lines.append("MOBILITY NEEDS:") + lines.append(f" {self.mobility_notes}") + + if self.accessibility_notes: + lines.append("") + lines.append("ACCESSIBILITY NOTES:") + lines.append(f" {self.accessibility_notes}") + + if self.special_requirements: + lines.append("") + lines.append("SPECIAL REQUIREMENTS:") + lines.append(f" {self.special_requirements}") + + return "\n".join(lines) + + def _generate_signed_documents(self): + """Generate document records for signed pages""" + self.ensure_one() + + ADPDocument = self.env['fusion.adp.document'].sudo() + + # Create Page 11 document if signature exists + if self.signature_page_11: + ADPDocument.create({ + 'assessment_id': self.id, + 'sale_order_id': self.sale_order_id.id if self.sale_order_id else False, + 'document_type': 'page_11', + 'file': self.signature_page_11, + 'filename': f'page_11_signature_{self.reference}.png', + 'mimetype': 'image/png', + 'source': 'assessment', + }) + + # Create Page 12 document if signature exists + if self.signature_page_12: + ADPDocument.create({ + 'assessment_id': self.id, + 'sale_order_id': self.sale_order_id.id if self.sale_order_id else False, + 'document_type': 'page_12', + 'file': self.signature_page_12, + 'filename': f'page_12_signature_{self.reference}.png', + 'mimetype': 'image/png', + 'source': 'assessment', + }) + + def _send_completion_notifications(self): + """Send email notifications when assessment is completed""" + self.ensure_one() + + # Send to authorizer + if self.authorizer_id and self.authorizer_id.email: + try: + template = self.env.ref('fusion_authorizer_portal.mail_template_assessment_complete_authorizer', raise_if_not_found=False) + if template: + template.send_mail(self.id, force_send=True) + _logger.info(f"Sent assessment completion email to authorizer {self.authorizer_id.email}") + except Exception as e: + _logger.error(f"Failed to send authorizer notification: {e}") + + # Send to client + if self.client_email: + try: + template = self.env.ref('fusion_authorizer_portal.mail_template_assessment_complete_client', raise_if_not_found=False) + if template: + template.send_mail(self.id, force_send=True) + _logger.info(f"Sent assessment completion email to client {self.client_email}") + except Exception as e: + _logger.error(f"Failed to send client notification: {e}") + + def action_view_documents(self): + """View related documents""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Documents'), + 'res_model': 'fusion.adp.document', + 'view_mode': 'list,form', + 'domain': [('assessment_id', '=', self.id)], + 'context': {'default_assessment_id': self.id}, + } + + def action_view_sale_order(self): + """View the created sale order""" + self.ensure_one() + if not self.sale_order_id: + raise UserError(_('No sale order has been created yet.')) + return { + 'type': 'ir.actions.act_window', + 'name': _('Sale Order'), + 'res_model': 'sale.order', + 'res_id': self.sale_order_id.id, + 'view_mode': 'form', + 'target': 'current', + } + + # ===== PDF TEMPLATE ENGINE INTEGRATION ===== + + def _get_pdf_context(self): + """Return a flat dict of all assessment data for PDF template filling. + + This is the data source for the generic PDF template engine. + Each key can be mapped to a field on any PDF template via field_key. + """ + self.ensure_one() + + ctx = { + # Client biographical + 'client_last_name': self.client_last_name or '', + 'client_first_name': self.client_first_name or '', + 'client_middle_name': self.client_middle_name or '', + 'client_name': self.client_name or '', + 'client_health_card': self.client_health_card or '', + 'client_health_card_version': self.client_health_card_version or '', + 'client_street': self.client_street or '', + 'client_unit': self.client_unit or '', + 'client_city': self.client_city or '', + 'client_state': self.client_state or '', + 'client_postal_code': self.client_postal_code or '', + 'client_phone': self.client_phone or '', + 'client_email': self.client_email or '', + 'client_weight': str(int(self.client_weight)) if self.client_weight else '', + + # Client type + 'client_type': self.client_type or '', + 'client_type_reg': self.client_type == 'reg', + 'client_type_ods': self.client_type == 'ods', + 'client_type_acs': self.client_type == 'acs', + 'client_type_owp': self.client_type == 'owp', + + # Consent & Declaration (Page 11) + '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(self.consent_date) if self.consent_date else '', + + # Agent info + 'agent_relationship': self.agent_relationship or '', + 'agent_rel_spouse': self.agent_relationship == 'spouse', + 'agent_rel_parent': self.agent_relationship == 'parent', + 'agent_rel_child': self.agent_relationship == 'child', + 'agent_rel_poa': self.agent_relationship == 'power_of_attorney', + 'agent_rel_guardian': self.agent_relationship == 'public_guardian', + '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_name 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_home_phone or '', + 'agent_business_phone': self.agent_business_phone or '', + 'agent_phone_ext': self.agent_phone_ext or '', + + # Equipment + 'equipment_type': self.equipment_type or '', + 'seat_width': str(self.seat_width) if self.seat_width else '', + 'seat_depth': str(self.seat_depth) if self.seat_depth else '', + 'seat_to_floor_height': str(self.seat_to_floor_height) if self.seat_to_floor_height else '', + 'back_height': str(self.back_height) if self.back_height else '', + 'legrest_length': str(self.legrest_length) if self.legrest_length else '', + 'cane_height': str(self.cane_height) if self.cane_height else '', + + # Dates + 'assessment_start_date': str(self.assessment_start_date) if self.assessment_start_date else '', + 'assessment_end_date': str(self.assessment_end_date) if self.assessment_end_date else '', + 'claim_authorization_date': str(self.claim_authorization_date) if self.claim_authorization_date else '', + + # Reason + 'reason_for_application': self.reason_for_application or '', + + # Reference + 'reference': self.reference or '', + } + + # Authorizer info + if self.authorizer_id: + ctx['authorizer_name'] = self.authorizer_id.name or '' + ctx['authorizer_phone'] = self.authorizer_id.phone or '' + ctx['authorizer_email'] = self.authorizer_id.email or '' + + return ctx + + def _get_pdf_signatures(self): + """Return a dict of signature binaries for PDF template filling.""" + self.ensure_one() + sigs = {} + if self.signature_page_11: + sigs['signature_page_11'] = base64.b64decode(self.signature_page_11) + if self.signature_page_12: + sigs['signature_page_12'] = base64.b64decode(self.signature_page_12) + return sigs + + def generate_template_pdf(self, template_name='adp_page_11'): + """Generate a filled PDF using the named template. + + Args: + template_name: the name field of the fusion.pdf.template record + + Returns: + bytes of the filled PDF, or None if template not found + """ + self.ensure_one() + + template = self.env['fusion.pdf.template'].search([ + ('state', '=', 'active'), + ('name', 'ilike', template_name), + ], limit=1) + + if not template: + _logger.warning("No active PDF template found matching '%s'", template_name) + return None + + context_data = self._get_pdf_context() + signatures = self._get_pdf_signatures() + + try: + pdf_bytes = template.generate_filled_pdf(context_data, signatures) + _logger.info("Generated filled PDF from template '%s' for assessment %s", + template.name, self.reference) + return pdf_bytes + except Exception as e: + _logger.error("Failed to generate PDF for assessment %s: %s", self.reference, e) + return None diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/models/authorizer_comment.py b/fusion_authorizer_portal/fusion_authorizer_portal/models/authorizer_comment.py new file mode 100644 index 0000000..50fdaea --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/models/authorizer_comment.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ +import logging + +_logger = logging.getLogger(__name__) + + +class AuthorizerComment(models.Model): + _name = 'fusion.authorizer.comment' + _description = 'Authorizer/Sales Rep Comment' + _order = 'create_date desc' + _rec_name = 'display_name' + + sale_order_id = fields.Many2one( + 'sale.order', + string='Sale Order', + required=True, + ondelete='cascade', + index=True, + ) + + assessment_id = fields.Many2one( + 'fusion.assessment', + string='Assessment', + ondelete='cascade', + index=True, + ) + + author_id = fields.Many2one( + 'res.partner', + string='Author', + required=True, + default=lambda self: self.env.user.partner_id, + index=True, + ) + + author_user_id = fields.Many2one( + 'res.users', + string='Author User', + default=lambda self: self.env.user, + index=True, + ) + + comment = fields.Text( + string='Comment', + required=True, + ) + + comment_type = fields.Selection([ + ('general', 'General Comment'), + ('question', 'Question'), + ('update', 'Status Update'), + ('internal', 'Internal Note'), + ], string='Type', default='general') + + is_internal = fields.Boolean( + string='Internal Only', + default=False, + help='If checked, this comment will not be visible to portal users', + ) + + display_name = fields.Char( + string='Display Name', + compute='_compute_display_name', + store=True, + ) + + @api.depends('author_id', 'create_date') + def _compute_display_name(self): + for comment in self: + if comment.author_id and comment.create_date: + comment.display_name = f"{comment.author_id.name} - {comment.create_date.strftime('%Y-%m-%d %H:%M')}" + else: + comment.display_name = _('New Comment') + + @api.model_create_multi + def create(self, vals_list): + """Override create to set author from current user if not provided""" + for vals in vals_list: + if not vals.get('author_id'): + vals['author_id'] = self.env.user.partner_id.id + if not vals.get('author_user_id'): + vals['author_user_id'] = self.env.user.id + return super().create(vals_list) diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/models/pdf_template.py b/fusion_authorizer_portal/fusion_authorizer_portal/models/pdf_template.py new file mode 100644 index 0000000..ab2de8a --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/models/pdf_template.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +# Fusion PDF Template Engine +# Generic system for filling any funding agency's PDF forms + +import base64 +import logging +from io import BytesIO + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class FusionPdfTemplate(models.Model): + _name = 'fusion.pdf.template' + _description = 'PDF Form Template' + _order = 'category, name' + + name = fields.Char(string='Template Name', required=True) + category = fields.Selection([ + ('adp', 'ADP - Assistive Devices Program'), + ('mod', 'March of Dimes'), + ('odsp', 'ODSP'), + ('hardship', 'Hardship Funding'), + ('other', 'Other'), + ], string='Funding Agency', required=True, default='adp') + version = fields.Char(string='Form Version', default='1.0') + state = fields.Selection([ + ('draft', 'Draft'), + ('active', 'Active'), + ('archived', 'Archived'), + ], string='Status', default='draft', tracking=True) + + # The actual PDF template file + pdf_file = fields.Binary(string='PDF Template', required=True, attachment=True) + pdf_filename = fields.Char(string='PDF Filename') + page_count = fields.Integer( + string='Page Count', + compute='_compute_page_count', + store=True, + ) + + # Page preview images for the visual editor + preview_ids = fields.One2many( + 'fusion.pdf.template.preview', 'template_id', + string='Page Previews', + ) + + # Field positions configured via the visual editor + field_ids = fields.One2many( + 'fusion.pdf.template.field', 'template_id', + string='Template Fields', + ) + field_count = fields.Integer( + string='Fields', + compute='_compute_field_count', + ) + + notes = fields.Text( + string='Notes', + help='Usage notes, which assessments/forms use this template', + ) + + def write(self, vals): + res = super().write(vals) + if 'pdf_file' in vals and vals['pdf_file']: + for rec in self: + try: + rec.action_generate_previews() + except Exception as e: + _logger.warning("Auto preview generation failed for %s: %s", rec.name, e) + return res + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for rec in records: + if rec.pdf_file: + try: + rec.action_generate_previews() + except Exception as e: + _logger.warning("Auto preview generation failed for %s: %s", rec.name, e) + return records + + @api.depends('pdf_file') + def _compute_page_count(self): + for rec in self: + if rec.pdf_file: + try: + from odoo.tools.pdf import PdfFileReader + pdf_data = base64.b64decode(rec.pdf_file) + reader = PdfFileReader(BytesIO(pdf_data)) + rec.page_count = reader.getNumPages() + except Exception as e: + _logger.warning("Could not read PDF page count: %s", e) + rec.page_count = 0 + else: + rec.page_count = 0 + + def action_generate_previews(self): + """Generate PNG preview images from the PDF using poppler (pdftoppm). + Falls back gracefully if the PDF is protected or poppler is not available. + """ + self.ensure_one() + if not self.pdf_file: + raise UserError(_('Please upload a PDF file first.')) + + import subprocess + import tempfile + import os + + pdf_data = base64.b64decode(self.pdf_file) + + try: + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, 'template.pdf') + with open(pdf_path, 'wb') as f: + f.write(pdf_data) + + # Use pdftoppm to convert each page to PNG + result = subprocess.run( + ['pdftoppm', '-png', '-r', '200', pdf_path, os.path.join(tmpdir, 'page')], + capture_output=True, timeout=30, + ) + + if result.returncode != 0: + stderr = result.stderr.decode('utf-8', errors='replace') + _logger.warning("pdftoppm failed: %s", stderr) + raise UserError(_( + 'Could not generate previews automatically. ' + 'The PDF may be protected. Please upload preview images manually ' + 'in the Page Previews tab (screenshots of each page).' + )) + + # Find generated PNG files + png_files = sorted([ + f for f in os.listdir(tmpdir) + if f.startswith('page-') and f.endswith('.png') + ]) + + if not png_files: + raise UserError(_('No pages were generated. Please upload preview images manually.')) + + # Delete existing previews + self.preview_ids.unlink() + + # Create preview records + for idx, png_file in enumerate(png_files): + png_path = os.path.join(tmpdir, png_file) + with open(png_path, 'rb') as f: + image_data = base64.b64encode(f.read()) + + self.env['fusion.pdf.template.preview'].create({ + 'template_id': self.id, + 'page': idx + 1, + 'image': image_data, + 'image_filename': f'page_{idx + 1}.png', + }) + + _logger.info("Generated %d preview images for template %s", len(png_files), self.name) + + except subprocess.TimeoutExpired: + raise UserError(_('PDF conversion timed out. Please upload preview images manually.')) + except FileNotFoundError: + raise UserError(_( + 'poppler-utils (pdftoppm) is not installed on the server. ' + 'Please upload preview images manually in the Page Previews tab.' + )) + + @api.depends('field_ids') + def _compute_field_count(self): + for rec in self: + rec.field_count = len(rec.field_ids) + + def action_activate(self): + """Set template to active.""" + self.ensure_one() + if not self.pdf_file: + raise UserError(_('Please upload a PDF file before activating.')) + self.state = 'active' + + def action_archive(self): + """Archive the template.""" + self.ensure_one() + self.state = 'archived' + + def action_reset_draft(self): + """Reset to draft.""" + self.ensure_one() + self.state = 'draft' + + def action_open_field_editor(self): + """Open the visual field position editor.""" + self.ensure_one() + return { + 'type': 'ir.actions.act_url', + 'url': f'/fusion/pdf-editor/{self.id}', + 'target': 'new', + } + + def generate_filled_pdf(self, context_data, signatures=None): + """Generate a filled PDF using this template and the provided data. + + Args: + context_data: flat dict of {field_key: value} + signatures: dict of {field_key: binary_png} for signature fields + + Returns: + bytes of the filled PDF + """ + self.ensure_one() + if not self.pdf_file: + raise UserError(_('Template has no PDF file.')) + if self.state != 'active': + _logger.warning("Generating PDF from non-active template %s", self.name) + + from ..utils.pdf_filler import PDFTemplateFiller + + template_bytes = base64.b64decode(self.pdf_file) + + # Build fields_by_page dict + fields_by_page = {} + for field in self.field_ids.filtered(lambda f: f.is_active): + page = field.page + if page not in fields_by_page: + fields_by_page[page] = [] + fields_by_page[page].append({ + 'field_name': field.name, + 'field_key': field.field_key or field.name, + 'pos_x': field.pos_x, + 'pos_y': field.pos_y, + 'width': field.width, + 'height': field.height, + 'field_type': field.field_type, + 'font_size': field.font_size, + 'font_name': field.font_name or 'Helvetica', + 'text_align': field.text_align or 'left', + }) + + return PDFTemplateFiller.fill_template( + template_bytes, fields_by_page, context_data, signatures + ) + + +class FusionPdfTemplatePreview(models.Model): + _name = 'fusion.pdf.template.preview' + _description = 'PDF Template Page Preview' + _order = 'page' + + template_id = fields.Many2one( + 'fusion.pdf.template', string='Template', + required=True, ondelete='cascade', index=True, + ) + page = fields.Integer(string='Page Number', required=True, default=1) + image = fields.Binary(string='Page Image (PNG)', attachment=True) + image_filename = fields.Char(string='Image Filename') + + +class FusionPdfTemplateField(models.Model): + _name = 'fusion.pdf.template.field' + _description = 'PDF Template Field' + _order = 'page, sequence' + + template_id = fields.Many2one( + 'fusion.pdf.template', string='Template', + required=True, ondelete='cascade', index=True, + ) + name = fields.Char( + string='Field Name', required=True, + help='Internal identifier, e.g. client_last_name', + ) + label = fields.Char( + string='Display Label', + help='Human-readable label shown in the editor, e.g. "Last Name"', + ) + sequence = fields.Integer(string='Sequence', default=10) + page = fields.Integer(string='Page', default=1, required=True) + + # Percentage-based positioning (0.0 to 1.0) -- same as sign.item + pos_x = fields.Float( + string='Position X', digits=(4, 3), + help='Horizontal position as ratio (0.0 = left edge, 1.0 = right edge)', + ) + pos_y = fields.Float( + string='Position Y', digits=(4, 3), + help='Vertical position as ratio (0.0 = top edge, 1.0 = bottom edge)', + ) + width = fields.Float( + string='Width', digits=(4, 3), default=0.150, + help='Width as ratio of page width', + ) + height = fields.Float( + string='Height', digits=(4, 3), default=0.015, + help='Height as ratio of page height', + ) + + # Rendering settings + field_type = fields.Selection([ + ('text', 'Text'), + ('checkbox', 'Checkbox'), + ('signature', 'Signature Image'), + ('date', 'Date'), + ], string='Field Type', default='text', required=True) + font_size = fields.Float(string='Font Size', default=10.0) + font_name = fields.Selection([ + ('Helvetica', 'Helvetica'), + ('Courier', 'Courier'), + ('Times-Roman', 'Times Roman'), + ], string='Font', default='Helvetica') + text_align = fields.Selection([ + ('left', 'Left'), + ('center', 'Center'), + ('right', 'Right'), + ], string='Text Alignment', default='left') + + # Data mapping + field_key = fields.Char( + string='Data Key', + help='Key to look up in the data context dict.\n' + 'Examples: client_last_name, client_health_card, consent_date, signature_page_11\n' + 'The generating code passes a flat dict of all available data.', + ) + default_value = fields.Char( + string='Default Value', + help='Fallback value if field_key returns empty', + ) + is_active = fields.Boolean(string='Active', default=True) diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/models/res_partner.py b/fusion_authorizer_portal/fusion_authorizer_portal/models/res_partner.py new file mode 100644 index 0000000..129a5a8 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/models/res_partner.py @@ -0,0 +1,764 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from markupsafe import Markup, escape +import logging + +_logger = logging.getLogger(__name__) + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + # Portal Role Flags + is_authorizer = fields.Boolean( + string='Is Authorizer', + default=False, + help='Check if this partner is an Authorizer (OT/Therapist) who can access the Authorizer Portal', + ) + is_sales_rep_portal = fields.Boolean( + string='Is Sales Rep (Portal)', + default=False, + help='Check if this partner is a Sales Rep who can access the Sales Rep Portal', + ) + is_client_portal = fields.Boolean( + string='Is Client (Portal)', + default=False, + help='Check if this partner can access the Funding Claims Portal to view their claims', + ) + is_technician_portal = fields.Boolean( + string='Is Technician (Portal)', + default=False, + help='Check if this partner is a Field Technician who can access the Technician Portal for deliveries', + ) + + # Computed field for assigned deliveries (for technicians) + assigned_delivery_count = fields.Integer( + string='Assigned Deliveries', + compute='_compute_assigned_delivery_count', + help='Number of sale orders assigned to this partner as delivery technician', + ) + + # Geocoding coordinates (for travel time calculations) + x_fc_latitude = fields.Float( + string='Latitude', + digits=(10, 7), + help='GPS latitude of the partner address (auto-geocoded)', + ) + x_fc_longitude = fields.Float( + string='Longitude', + digits=(10, 7), + help='GPS longitude of the partner address (auto-geocoded)', + ) + + # Link to portal user account + authorizer_portal_user_id = fields.Many2one( + 'res.users', + string='Portal User Account', + help='The portal user account linked to this authorizer/sales rep', + copy=False, + ) + + # Portal access status tracking + portal_access_status = fields.Selection( + selection=[ + ('no_access', 'No Access'), + ('invited', 'Invited'), + ('active', 'Active'), + ], + string='Portal Status', + compute='_compute_portal_access_status', + store=True, + help='Tracks portal access: No Access = no portal user, Invited = user created but never logged in, Active = user has logged in', + ) + + # Computed counts + assigned_case_count = fields.Integer( + string='Assigned Cases', + compute='_compute_assigned_case_count', + help='Number of sale orders assigned to this partner as authorizer', + ) + + assessment_count = fields.Integer( + string='Assessments', + compute='_compute_assessment_count', + help='Number of assessments linked to this partner', + ) + + @api.depends('authorizer_portal_user_id', 'authorizer_portal_user_id.login_date') + def _compute_portal_access_status(self): + """Compute portal access status based on user account and login history.""" + for partner in self: + if not partner.authorizer_portal_user_id: + partner.portal_access_status = 'no_access' + elif partner.authorizer_portal_user_id.login_date: + partner.portal_access_status = 'active' + else: + partner.portal_access_status = 'invited' + + @api.depends('is_authorizer') + def _compute_assigned_case_count(self): + """Count sale orders where this partner is the authorizer""" + SaleOrder = self.env['sale.order'].sudo() + for partner in self: + if partner.is_authorizer: + # Use x_fc_authorizer_id field from fusion_claims + domain = [('x_fc_authorizer_id', '=', partner.id)] + partner.assigned_case_count = SaleOrder.search_count(domain) + else: + partner.assigned_case_count = 0 + + @api.depends('is_authorizer', 'is_sales_rep_portal') + def _compute_assessment_count(self): + """Count assessments where this partner is involved""" + Assessment = self.env['fusion.assessment'].sudo() + for partner in self: + count = 0 + if partner.is_authorizer: + count += Assessment.search_count([('authorizer_id', '=', partner.id)]) + if partner.is_sales_rep_portal and partner.authorizer_portal_user_id: + count += Assessment.search_count([('sales_rep_id', '=', partner.authorizer_portal_user_id.id)]) + partner.assessment_count = count + + @api.depends('is_technician_portal') + def _compute_assigned_delivery_count(self): + """Count sale orders assigned to this partner as delivery technician""" + SaleOrder = self.env['sale.order'].sudo() + for partner in self: + if partner.is_technician_portal and partner.authorizer_portal_user_id: + # Technicians are linked via user_id in x_fc_delivery_technician_ids + domain = [('x_fc_delivery_technician_ids', 'in', [partner.authorizer_portal_user_id.id])] + partner.assigned_delivery_count = SaleOrder.search_count(domain) + else: + partner.assigned_delivery_count = 0 + + def _assign_portal_role_groups(self, portal_user): + """Assign role-specific portal groups to a portal user based on contact checkboxes.""" + groups_to_add = [] + if self.is_technician_portal: + g = self.env.ref('fusion_authorizer_portal.group_technician_portal', raise_if_not_found=False) + if g and g not in portal_user.group_ids: + groups_to_add.append((4, g.id)) + if self.is_authorizer: + g = self.env.ref('fusion_authorizer_portal.group_authorizer_portal', raise_if_not_found=False) + if g and g not in portal_user.group_ids: + groups_to_add.append((4, g.id)) + if self.is_sales_rep_portal: + g = self.env.ref('fusion_authorizer_portal.group_sales_rep_portal', raise_if_not_found=False) + if g and g not in portal_user.group_ids: + groups_to_add.append((4, g.id)) + if groups_to_add: + portal_user.sudo().write({'group_ids': groups_to_add}) + + def _assign_internal_role_groups(self, internal_user): + """Assign backend groups to an internal user based on contact checkboxes. + Also sets x_fc_is_field_staff so the user appears in technician/staff dropdowns. + Returns list of group names that were added.""" + added = [] + needs_field_staff = False + + if self.is_technician_portal: + # Add Field Technician group + g = self.env.ref('fusion_claims.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') + needs_field_staff = True + + if self.is_sales_rep_portal: + # Internal sales reps don't need a portal group but should show in staff dropdowns + added.append('Sales Rep (internal)') + needs_field_staff = True + + if self.is_authorizer: + # Internal authorizers already have full backend access + added.append('Authorizer (internal)') + + # Mark as field staff so they appear in technician/delivery dropdowns + if needs_field_staff and hasattr(internal_user, 'x_fc_is_field_staff'): + if not internal_user.x_fc_is_field_staff: + internal_user.sudo().write({'x_fc_is_field_staff': True}) + added.append('Field Staff') + + return added + + def action_grant_portal_access(self): + """Grant portal access to this partner, or update permissions for existing users.""" + self.ensure_one() + + if not self.email: + raise UserError(_('Please set an email address before granting portal access.')) + + email_normalized = self.email.strip().lower() + + # ── Step 1: Find existing user ── + # Search by partner_id first (direct link) + existing_user = self.env['res.users'].sudo().search([ + ('partner_id', '=', self.id), + ], limit=1) + + # If not found by partner, search by email (handles internal users + # whose auto-created partner is different from this contact) + if not existing_user: + existing_user = self.env['res.users'].sudo().search([ + '|', + ('login', '=ilike', email_normalized), + ('email', '=ilike', email_normalized), + ], limit=1) + + # ── Step 2: Handle existing user ── + if existing_user: + from datetime import datetime + self.authorizer_portal_user_id = existing_user + + if not existing_user.share: + # ── INTERNAL user: assign backend groups, do NOT add portal ── + groups_added = self._assign_internal_role_groups(existing_user) + groups_text = ', '.join(groups_added) if groups_added else 'No new groups needed' + chatter_msg = Markup( + '
    ' + '
    ' + ' Internal User — Permissions Updated' + '
    ' + '
    ' + '' + f'' + f'' + f'' + f'' + f'' + f'' + '
    User:{escape(existing_user.name)} (ID: {existing_user.id})
    Login:{escape(existing_user.login)}
    Type:Internal (backend) user
    Groups added:{escape(groups_text)}
    Updated by:{escape(self.env.user.name)}
    Updated at:{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
    ' + '
    ' + '
    ' + ) + self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note') + notify_msg = _('Internal user detected. Backend permissions updated: %s') % groups_text + else: + # ── Existing PORTAL user: ensure role groups are set ── + portal_group = self.env.ref('base.group_portal', raise_if_not_found=False) + if portal_group and portal_group not in existing_user.group_ids: + existing_user.sudo().write({'group_ids': [(4, portal_group.id)]}) + self._assign_portal_role_groups(existing_user) + chatter_msg = Markup( + '
    ' + '
    ' + ' Portal Access — Roles Updated' + '
    ' + '
    ' + '' + f'' + f'' + f'' + f'' + f'' + '
    Status:Portal user exists — roles updated
    User:{escape(existing_user.name)} (ID: {existing_user.id})
    Login:{escape(existing_user.login)}
    Checked by:{escape(self.env.user.name)}
    Checked at:{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
    ' + '
    ' + '
    ' + ) + self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note') + notify_msg = _('Portal user already exists — role groups updated (User ID: %s).') % existing_user.id + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Access Updated'), + 'message': notify_msg, + 'type': 'info', + 'sticky': False, + } + } + + # No existing user found - create portal user directly + portal_group = self.env.ref('base.group_portal', raise_if_not_found=False) + if not portal_group: + raise UserError(_('Portal group not found. Please contact administrator.')) + + try: + # Create user without groups first (Odoo 17+ compatibility) + portal_user = self.env['res.users'].sudo().with_context(no_reset_password=True, knowledge_skip_onboarding_article=True).create({ + 'name': self.name, + 'login': email_normalized, + 'email': self.email, + 'partner_id': self.id, + 'active': True, + }) + # Add portal group after creation + portal_user.sudo().write({ + 'group_ids': [(6, 0, [portal_group.id])], + }) + # Assign role-specific portal groups based on contact checkboxes + self._assign_portal_role_groups(portal_user) + self.authorizer_portal_user_id = portal_user + + # Create welcome Knowledge article for the user + self._create_welcome_article(portal_user) + + # Send professional portal invitation email + email_sent = False + try: + email_sent = self._send_portal_invitation_email(portal_user) + except Exception as mail_error: + _logger.warning(f"Could not send portal invitation email: {mail_error}") + + # Post message in chatter + sent_by = self.env.user.name + from datetime import datetime + sent_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + if email_sent: + status_text = 'Invitation email sent successfully' + border_color = '#28a745' + header_bg = '#28a745' + body_bg = '#f0fff0' + else: + status_text = 'User created but email could not be sent' + border_color = '#fd7e14' + header_bg = '#fd7e14' + body_bg = '#fff8f0' + + chatter_msg = Markup( + f'
    ' + f'
    ' + ' Portal Access Granted' + '
    ' + f'
    ' + '' + f'' + f'' + f'' + f'' + f'' + '
    Email:{self.email}
    Sent by:{sent_by}
    Sent at:{sent_at}
    User ID:{portal_user.id}
    Status:{status_text}
    ' + '
    ' + '
    ' + ) + + self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note') + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Portal Access Granted'), + 'message': _('Portal user created for %s. A password reset email has been sent.') % self.email, + 'type': 'success', + 'sticky': False, + } + } + except Exception as e: + _logger.error(f"Failed to create portal user: {e}") + raise UserError(_('Failed to create portal user: %s') % str(e)) + + def _create_welcome_article(self, portal_user): + """Create a role-specific welcome Knowledge article for the new portal user. + + Determines the role from partner flags and renders the matching template. + The article is private to the user and set as a favorite. + """ + self.ensure_one() + + # Check if Knowledge module is installed + if 'knowledge.article' not in self.env: + _logger.info("Knowledge module not installed, skipping welcome article") + return + + # Determine role and template + if self.is_technician_portal: + template_xmlid = 'fusion_authorizer_portal.welcome_article_technician' + icon = '🔧' + title = f"Welcome {self.name} - Technician Portal" + elif self.is_authorizer: + template_xmlid = 'fusion_authorizer_portal.welcome_article_authorizer' + icon = '📋' + title = f"Welcome {self.name} - Authorizer Portal" + elif self.is_sales_rep_portal: + template_xmlid = 'fusion_authorizer_portal.welcome_article_sales_rep' + icon = '💼' + title = f"Welcome {self.name} - Sales Portal" + elif self.is_client_portal: + template_xmlid = 'fusion_authorizer_portal.welcome_article_client' + icon = '👤' + title = f"Welcome {self.name}" + else: + template_xmlid = 'fusion_authorizer_portal.welcome_article_client' + icon = '👋' + title = f"Welcome {self.name}" + + company = self.env.company + render_ctx = { + 'user_name': self.name or 'Valued Partner', + 'company_name': company.name or 'Our Company', + 'company_email': company.email or '', + 'company_phone': company.phone or '', + } + + try: + body = self.env['ir.qweb']._render( + template_xmlid, + render_ctx, + minimal_qcontext=True, + raise_if_not_found=False, + ) + + if not body: + _logger.warning(f"Welcome article template not found: {template_xmlid}") + return + + article = self.env['knowledge.article'].sudo().create({ + 'name': title, + 'icon': icon, + 'body': body, + 'internal_permission': 'none', + 'is_article_visible_by_everyone': False, + 'article_member_ids': [(0, 0, { + 'partner_id': self.id, + 'permission': 'write', + })], + 'favorite_ids': [(0, 0, { + 'sequence': 0, + 'user_id': portal_user.id, + })], + }) + + _logger.info(f"Created welcome article '{title}' (ID: {article.id}) for {self.name}") + + except Exception as e: + _logger.warning(f"Failed to create welcome article for {self.name}: {e}") + + def _send_portal_invitation_email(self, portal_user, is_resend=False): + """Send a professional portal invitation email to the partner. + + Generates a signup URL and sends a branded invitation email + instead of the generic Odoo password reset email. + + Returns True if email was sent successfully, False otherwise. + """ + self.ensure_one() + + # Generate signup token and build URL + partner = portal_user.sudo().partner_id + + # Set signup type to 'signup' - this auto-logs in after password is set + partner.signup_prepare(signup_type='signup') + + # Use Odoo's built-in URL generation with signup_email context + # so the email is pre-filled and user just sets password + signup_urls = partner.with_context( + signup_valid=True, + create_user=True, + )._get_signup_url_for_action() + signup_url = signup_urls.get(partner.id) + + if not signup_url: + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + signup_url = f"{base_url}/web/reset_password" + _logger.warning(f"Could not generate signup URL for {self.email}, using generic reset page") + + company = self.env.company + company_name = company.name or 'Our Company' + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + partner_name = self.name or 'Valued Partner' + + subject = f"You're Invited to the {company_name} Portal" if not is_resend else f"Portal Access Reminder - {company_name}" + + invite_text = 'We are pleased to invite you' if not is_resend else 'This is a reminder that you have been invited' + body_html = ( + f'
    ' + f'
    ' + f'
    ' + f'

    {company_name}

    ' + f'

    Portal Invitation

    ' + f'

    ' + f'Dear {partner_name}, {invite_text} to access the {company_name} Portal.

    ' + f'

    ' + f'With the portal you can:

    ' + f'
      ' + f'
    • View and manage your assigned cases
    • ' + f'
    • Complete assessments online
    • ' + f'
    • Track application status and progress
    • ' + f'
    • Access important documents
    ' + f'

    ' + f'' + f'Accept Invitation & Set Password

    ' + f'

    ' + f'If the button does not work, copy this link: ' + f'{signup_url}

    ' + f'
    ' + f'

    ' + f'After setting your password, access the portal anytime at: ' + f'{base_url}/my

    ' + f'

    ' + f'Best regards,
    {company_name} Team

    ' + f'
    ' + f'
    ' + f'

    ' + f'This is an automated message from {company_name}.

    ' + ) + + mail_values = { + 'subject': subject, + 'body_html': body_html, + 'email_to': self.email, + 'email_from': company.email or self.env.user.email or 'noreply@example.com', + 'auto_delete': True, + } + + try: + mail = self.env['mail.mail'].sudo().create(mail_values) + mail.send() + _logger.info(f"Portal invitation email sent to {self.email}") + return True + except Exception as e: + _logger.error(f"Failed to send portal invitation email to {self.email}: {e}") + return False + + def action_resend_portal_invitation(self): + """Resend portal invitation email to an existing portal user.""" + self.ensure_one() + + if not self.authorizer_portal_user_id: + raise UserError(_('No portal user found for this contact. Use "Send Portal Invitation" instead.')) + + portal_user = self.authorizer_portal_user_id + + # Send professional portal invitation email + email_sent = False + try: + email_sent = self._send_portal_invitation_email(portal_user, is_resend=True) + except Exception as mail_error: + _logger.warning(f"Could not send portal invitation email: {mail_error}") + + # Post in chatter + from datetime import datetime + sent_by = self.env.user.name + sent_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + if email_sent: + chatter_msg = Markup( + '
    ' + '
    ' + ' Portal Invitation Resent' + '
    ' + '
    ' + '' + f'' + f'' + f'' + f'' + '' + '' + '
    Email:{self.email}
    Sent by:{sent_by}
    Sent at:{sent_at}
    User ID:{portal_user.id}
    Status:Invitation email resent successfully
    ' + '
    ' + '
    ' + ) + else: + chatter_msg = Markup( + '
    ' + '
    ' + ' Portal Invitation Resend Attempted' + '
    ' + '
    ' + '' + f'' + f'' + f'' + '' + '' + '
    Email:{self.email}
    Sent by:{sent_by}
    Sent at:{sent_at}
    Status:Email could not be sent - check mail configuration
    ' + '
    ' + '
    ' + ) + + self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note') + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Portal Invitation Resent') if email_sent else _('Email Failed'), + 'message': _('Portal invitation resent to %s.') % self.email if email_sent else _('Could not send email. Check mail configuration.'), + 'type': 'success' if email_sent else 'warning', + 'sticky': False, + } + } + + def action_view_assigned_cases(self): + """Open the list of assigned sale orders""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Assigned Cases'), + 'res_model': 'sale.order', + 'view_mode': 'list,form', + 'domain': [('x_fc_authorizer_id', '=', self.id)], + 'context': {'default_x_fc_authorizer_id': self.id}, + } + + def action_view_assessments(self): + """Open the list of assessments for this partner""" + self.ensure_one() + domain = [] + if self.is_authorizer: + domain = [('authorizer_id', '=', self.id)] + elif self.is_sales_rep_portal and self.authorizer_portal_user_id: + domain = [('sales_rep_id', '=', self.authorizer_portal_user_id.id)] + + return { + 'type': 'ir.actions.act_window', + 'name': _('Assessments'), + 'res_model': 'fusion.assessment', + 'view_mode': 'list,form', + 'domain': domain, + } + + # ==================== BATCH ACTIONS ==================== + + def action_mark_as_authorizer(self): + """Batch action to mark selected contacts as authorizers""" + self.write({'is_authorizer': True}) + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Authorizers Updated'), + 'message': _('%d contact(s) marked as authorizer.') % len(self), + 'type': 'success', + 'sticky': False, + } + } + + def action_batch_send_portal_invitation(self): + """Batch action to send portal invitations to selected authorizers""" + sent_count = 0 + skipped_no_email = 0 + skipped_not_authorizer = 0 + skipped_has_access = 0 + errors = [] + + for partner in self: + if not partner.is_authorizer: + skipped_not_authorizer += 1 + continue + if not partner.email: + skipped_no_email += 1 + continue + if partner.authorizer_portal_user_id: + skipped_has_access += 1 + continue + + try: + partner.action_grant_portal_access() + sent_count += 1 + except Exception as e: + errors.append(f"{partner.name}: {str(e)}") + + # Build result message + messages = [] + if sent_count: + messages.append(_('%d invitation(s) sent successfully.') % sent_count) + if skipped_not_authorizer: + messages.append(_('%d skipped (not marked as authorizer).') % skipped_not_authorizer) + if skipped_no_email: + messages.append(_('%d skipped (no email).') % skipped_no_email) + if skipped_has_access: + messages.append(_('%d skipped (already has portal access).') % skipped_has_access) + if errors: + messages.append(_('%d error(s) occurred.') % len(errors)) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Portal Invitations'), + 'message': ' '.join(messages), + 'type': 'success' if sent_count and not errors else 'warning' if not errors else 'danger', + 'sticky': True if errors else False, + } + } + + def action_mark_and_send_invitation(self): + """Combined action: mark as authorizer and send invitation""" + self.action_mark_as_authorizer() + return self.action_batch_send_portal_invitation() + + def action_view_assigned_deliveries(self): + """Open the list of assigned deliveries for technician""" + self.ensure_one() + if not self.authorizer_portal_user_id: + raise UserError(_('This partner does not have a portal user account.')) + return { + 'type': 'ir.actions.act_window', + 'name': _('Assigned Deliveries'), + 'res_model': 'sale.order', + 'view_mode': 'list,form', + 'domain': [('x_fc_delivery_technician_ids', 'in', [self.authorizer_portal_user_id.id])], + } + + def action_mark_as_technician(self): + """Batch action to mark selected contacts as technicians""" + self.write({'is_technician_portal': True}) + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Technicians Updated'), + 'message': _('%d contact(s) marked as technician.') % len(self), + 'type': 'success', + 'sticky': False, + } + } + + # ------------------------------------------------------------------ + # GEOCODING + # ------------------------------------------------------------------ + + def _geocode_address(self): + """Geocode partner address using Google Geocoding API and cache lat/lng.""" + import requests as http_requests + api_key = self.env['ir.config_parameter'].sudo().get_param( + 'fusion_claims.google_maps_api_key', '' + ) + if not api_key: + return + + for partner in self: + parts = [partner.street, partner.city, + partner.state_id.name if partner.state_id else '', + partner.zip] + address = ', '.join([p for p in parts if p]) + if not address: + continue + try: + resp = http_requests.get( + 'https://maps.googleapis.com/maps/api/geocode/json', + params={'address': address, 'key': api_key, 'region': 'ca'}, + timeout=10, + ) + data = resp.json() + if data.get('status') == 'OK' and data.get('results'): + loc = data['results'][0]['geometry']['location'] + partner.write({ + 'x_fc_latitude': loc['lat'], + 'x_fc_longitude': loc['lng'], + }) + except Exception as e: + _logger.warning(f"Geocoding failed for partner {partner.id}: {e}") + + def write(self, vals): + """Override write to auto-geocode when address changes.""" + res = super().write(vals) + address_fields = {'street', 'city', 'state_id', 'zip', 'country_id'} + if address_fields & set(vals.keys()): + # Check if distance matrix is enabled before geocoding + enabled = self.env['ir.config_parameter'].sudo().get_param( + 'fusion_claims.google_distance_matrix_enabled', False + ) + if enabled: + self._geocode_address() + return res diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/models/res_users.py b/fusion_authorizer_portal/fusion_authorizer_portal/models/res_users.py new file mode 100644 index 0000000..0e09d01 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/models/res_users.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- + +from odoo import api, models, _ +from odoo.exceptions import UserError +import logging + +_logger = logging.getLogger(__name__) + + +class PortalWizardUser(models.TransientModel): + """Override standard portal wizard to handle internal users with Fusion roles.""" + _inherit = 'portal.wizard.user' + + def action_grant_access(self): + """Override: Handle Fusion portal roles when granting portal access. + - Internal users with Fusion roles: assign backend groups, skip portal. + - Portal users with Fusion roles: standard flow + assign role groups. + """ + self.ensure_one() + partner = self.partner_id + + # Check if the partner has any Fusion portal flags + has_fusion_role = getattr(partner, 'is_technician_portal', False) or \ + getattr(partner, 'is_authorizer', False) or \ + getattr(partner, 'is_sales_rep_portal', False) + + # Find the linked user + user = self.user_id + if user and user._is_internal() and has_fusion_role: + # Internal user with Fusion roles -- assign backend groups, no portal + partner._assign_internal_role_groups(user) + partner.authorizer_portal_user_id = user + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Internal User Updated'), + 'message': _('%s is an internal user. Backend permissions updated (no portal access needed).') % partner.name, + 'type': 'info', + 'sticky': True, + } + } + + # Standard Odoo portal flow (creates user, sends email, etc.) + result = super().action_grant_access() + + # After standard flow, assign Fusion portal role groups + if has_fusion_role: + portal_user = self.user_id + if not portal_user: + # Fallback: find the user that was just created + portal_user = self.env['res.users'].sudo().search([ + ('partner_id', '=', partner.id), + ('share', '=', True), + ('active', '=', True), + ], limit=1) + if portal_user: + partner._assign_portal_role_groups(portal_user) + if not partner.authorizer_portal_user_id: + partner.authorizer_portal_user_id = portal_user + _logger.info("Assigned Fusion portal role groups to user %s (partner: %s)", + portal_user.login, partner.name) + + return result + + +class ResUsers(models.Model): + _inherit = 'res.users' + + def _generate_tutorial_articles(self): + """Override to create custom welcome articles for internal staff + instead of the default Odoo Knowledge onboarding article. + """ + if 'knowledge.article' not in self.env: + return super()._generate_tutorial_articles() + + for user in self: + company = user.company_id or self.env.company + render_ctx = { + 'user_name': user.name or 'Team Member', + 'company_name': company.name or 'Our Company', + 'company_email': company.email or '', + 'company_phone': company.phone or '', + } + + try: + body = self.env['ir.qweb']._render( + 'fusion_authorizer_portal.welcome_article_internal', + render_ctx, + minimal_qcontext=True, + raise_if_not_found=False, + ) + + if not body: + _logger.warning("Internal staff welcome template not found, using default") + return super()._generate_tutorial_articles() + + self.env['knowledge.article'].sudo().create({ + 'name': f"Welcome {user.name} - {company.name}", + 'icon': 'ðŸ¢', + 'body': body, + 'internal_permission': 'none', + 'is_article_visible_by_everyone': False, + 'article_member_ids': [(0, 0, { + 'partner_id': user.partner_id.id, + 'permission': 'write', + })], + 'favorite_ids': [(0, 0, { + 'sequence': 0, + 'user_id': user.id, + })], + }) + + _logger.info(f"Created custom welcome article for internal user {user.name}") + + except Exception as e: + _logger.warning(f"Failed to create custom welcome article for {user.name}: {e}") + # Fall back to default + super(ResUsers, user)._generate_tutorial_articles() diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/models/sale_order.py b/fusion_authorizer_portal/fusion_authorizer_portal/models/sale_order.py new file mode 100644 index 0000000..a216c6a --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/models/sale_order.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ +import logging + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + # Comments from portal users + portal_comment_ids = fields.One2many( + 'fusion.authorizer.comment', + 'sale_order_id', + string='Portal Comments', + ) + + portal_comment_count = fields.Integer( + string='Comment Count', + compute='_compute_portal_comment_count', + ) + + # Documents uploaded via portal + portal_document_ids = fields.One2many( + 'fusion.adp.document', + 'sale_order_id', + string='Portal Documents', + ) + + portal_document_count = fields.Integer( + string='Document Count', + compute='_compute_portal_document_count', + ) + + # Link to assessment + assessment_id = fields.Many2one( + 'fusion.assessment', + string='Source Assessment', + readonly=True, + help='The assessment that created this sale order', + ) + + # Authorizer helper field (consolidates multiple possible fields) + portal_authorizer_id = fields.Many2one( + 'res.partner', + string='Authorizer (Portal)', + compute='_compute_portal_authorizer_id', + store=True, + help='Consolidated authorizer field for portal access', + ) + + @api.depends('portal_comment_ids') + def _compute_portal_comment_count(self): + for order in self: + order.portal_comment_count = len(order.portal_comment_ids) + + @api.depends('portal_document_ids') + def _compute_portal_document_count(self): + for order in self: + order.portal_document_count = len(order.portal_document_ids) + + @api.depends('x_fc_authorizer_id') + def _compute_portal_authorizer_id(self): + """Get authorizer from x_fc_authorizer_id field""" + for order in self: + order.portal_authorizer_id = order.x_fc_authorizer_id + + def write(self, vals): + """Override write to send notification when authorizer is assigned.""" + old_authorizers = { + order.id: order.x_fc_authorizer_id.id if order.x_fc_authorizer_id else False + for order in self + } + + result = super().write(vals) + + # Check for authorizer changes + if 'x_fc_authorizer_id' in vals: + for order in self: + old_auth = old_authorizers.get(order.id) + new_auth = vals.get('x_fc_authorizer_id') + if new_auth and new_auth != old_auth: + order._send_authorizer_assignment_notification() + + # NOTE: Generic status change notifications removed. + # Each status transition already sends its own detailed email + # from fusion_claims (approval, denial, submission, billed, etc.) + # A generic "status changed" email on top was redundant and lacked detail. + + return result + + def action_message_authorizer(self): + """Open composer to send message to authorizer only""" + self.ensure_one() + if not self.x_fc_authorizer_id: + return {'type': 'ir.actions.act_window_close'} + + return { + 'type': 'ir.actions.act_window', + 'name': 'Message Authorizer', + 'res_model': 'mail.compose.message', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_model': 'sale.order', + 'default_res_ids': [self.id], + 'default_partner_ids': [self.x_fc_authorizer_id.id], + 'default_composition_mode': 'comment', + 'default_subtype_xmlid': 'mail.mt_note', + }, + } + + def _send_authorizer_assignment_notification(self): + """Send email when an authorizer is assigned to the order""" + self.ensure_one() + + if not self.x_fc_authorizer_id or not self.x_fc_authorizer_id.email: + return + + try: + template = self.env.ref('fusion_authorizer_portal.mail_template_case_assigned', raise_if_not_found=False) + if template: + template.send_mail(self.id, force_send=False) + _logger.info(f"Sent case assignment notification to {self.x_fc_authorizer_id.email} for {self.name}") + except Exception as e: + _logger.error(f"Failed to send authorizer assignment notification: {e}") + + # _send_status_change_notification removed -- redundant. + # Each workflow transition in fusion_claims sends its own detailed email. + + def action_view_portal_comments(self): + """View portal comments""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Portal Comments'), + 'res_model': 'fusion.authorizer.comment', + 'view_mode': 'list,form', + 'domain': [('sale_order_id', '=', self.id)], + 'context': {'default_sale_order_id': self.id}, + } + + def action_view_portal_documents(self): + """View portal documents""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Portal Documents'), + 'res_model': 'fusion.adp.document', + 'view_mode': 'list,form', + 'domain': [('sale_order_id', '=', self.id)], + 'context': {'default_sale_order_id': self.id}, + } + + def get_portal_display_data(self): + """Get data for portal display, excluding sensitive information""" + self.ensure_one() + + return { + 'id': self.id, + 'name': self.name, + 'date_order': self.date_order, + 'state': self.state, + 'state_display': dict(self._fields['state'].selection).get(self.state, self.state), + 'partner_name': self.partner_id.name if self.partner_id else '', + 'partner_address': self._get_partner_address_display(), + 'client_reference_1': self.x_fc_client_ref_1 or '', + 'client_reference_2': self.x_fc_client_ref_2 or '', + 'claim_number': self.x_fc_claim_number or '', + 'authorizer_name': self.x_fc_authorizer_id.name if self.x_fc_authorizer_id else '', + 'sales_rep_name': self.user_id.name if self.user_id else '', + 'product_lines': self._get_product_lines_for_portal(), + 'comment_count': self.portal_comment_count, + 'document_count': self.portal_document_count, + } + + def _get_partner_address_display(self): + """Get formatted partner address for display""" + if not self.partner_id: + return '' + + parts = [] + if self.partner_id.street: + parts.append(self.partner_id.street) + if self.partner_id.city: + city_part = self.partner_id.city + if self.partner_id.state_id: + city_part += f", {self.partner_id.state_id.name}" + if self.partner_id.zip: + city_part += f" {self.partner_id.zip}" + parts.append(city_part) + + return ', '.join(parts) + + def _get_product_lines_for_portal(self): + """Get product lines for portal display (excluding costs)""" + lines = [] + for line in self.order_line: + lines.append({ + 'id': line.id, + 'product_name': line.product_id.name if line.product_id else line.name, + 'quantity': line.product_uom_qty, + 'uom': line.product_uom_id.name if line.product_uom_id else '', + 'adp_code': line.x_fc_adp_device_code or '' if hasattr(line, 'x_fc_adp_device_code') else '', + 'device_type': '', + 'serial_number': line.x_fc_serial_number or '' if hasattr(line, 'x_fc_serial_number') else '', + }) + return lines + + @api.model + def get_authorizer_portal_cases(self, partner_id, search_query=None, limit=100, offset=0): + """Get cases for authorizer portal with optional search""" + domain = [('x_fc_authorizer_id', '=', partner_id)] + + # Add search if provided + if search_query: + search_domain = self._build_search_domain(search_query) + domain = ['&'] + domain + search_domain + + orders = self.sudo().search(domain, limit=limit, offset=offset, order='date_order desc, id desc') + return orders + + @api.model + def get_sales_rep_portal_cases(self, user_id, search_query=None, limit=100, offset=0): + """Get cases for sales rep portal with optional search""" + domain = [('user_id', '=', user_id)] + + # Add search if provided + if search_query: + search_domain = self._build_search_domain(search_query) + domain = domain + search_domain + + orders = self.sudo().search(domain, limit=limit, offset=offset, order='date_order desc, id desc') + return orders + + def _build_search_domain(self, query): + """Build search domain for portal search""" + if not query or len(query) < 2: + return [] + + search_domain = [ + '|', '|', '|', '|', + ('partner_id.name', 'ilike', query), + ('x_fc_claim_number', 'ilike', query), + ('x_fc_client_ref_1', 'ilike', query), + ('x_fc_client_ref_2', 'ilike', query), + ] + + return search_domain diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/security/ir.model.access.csv b/fusion_authorizer_portal/fusion_authorizer_portal/security/ir.model.access.csv new file mode 100644 index 0000000..10c6658 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_authorizer_comment_user,fusion.authorizer.comment.user,model_fusion_authorizer_comment,base.group_user,1,1,1,1 +access_fusion_authorizer_comment_portal,fusion.authorizer.comment.portal,model_fusion_authorizer_comment,base.group_portal,1,1,1,0 +access_fusion_adp_document_user,fusion.adp.document.user,model_fusion_adp_document,base.group_user,1,1,1,1 +access_fusion_adp_document_portal,fusion.adp.document.portal,model_fusion_adp_document,base.group_portal,1,0,1,0 +access_fusion_assessment_user,fusion.assessment.user,model_fusion_assessment,base.group_user,1,1,1,1 +access_fusion_assessment_portal,fusion.assessment.portal,model_fusion_assessment,base.group_portal,1,1,1,0 +access_fusion_accessibility_assessment_user,fusion.accessibility.assessment.user,model_fusion_accessibility_assessment,base.group_user,1,1,1,1 +access_fusion_accessibility_assessment_portal,fusion.accessibility.assessment.portal,model_fusion_accessibility_assessment,base.group_portal,1,1,1,0 +access_fusion_pdf_template_user,fusion.pdf.template.user,model_fusion_pdf_template,base.group_user,1,1,1,1 +access_fusion_pdf_template_preview_user,fusion.pdf.template.preview.user,model_fusion_pdf_template_preview,base.group_user,1,1,1,1 +access_fusion_pdf_template_field_user,fusion.pdf.template.field.user,model_fusion_pdf_template_field,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/security/portal_security.xml b/fusion_authorizer_portal/fusion_authorizer_portal/security/portal_security.xml new file mode 100644 index 0000000..5b0228a --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/security/portal_security.xml @@ -0,0 +1,140 @@ + + + + + + Authorizer Portal User + + Portal users who are Authorizers (OTs/Therapists) + + + + Sales Rep Portal User + + Portal users who are Sales Representatives + + + + Technician Portal User + + Portal users who are Field Technicians for deliveries + + + + + Authorizer: Own Comments + + [('author_id', '=', user.partner_id.id)] + + + + + + + + + Portal: View Comments on Assigned Orders + + [ + '|', + ('sale_order_id.x_fc_authorizer_id', '=', user.partner_id.id), + ('sale_order_id.user_id', '=', user.id), + ('is_internal', '=', False) + ] + + + + + + + + + + Portal: Read Documents on Assigned Orders + + [ + '|', + ('sale_order_id.x_fc_authorizer_id', '=', user.partner_id.id), + ('sale_order_id.user_id', '=', user.id) + ] + + + + + + + + + Authorizer: Create Documents on Assigned Orders + + [ + ('sale_order_id.x_fc_authorizer_id', '=', user.partner_id.id), + ('document_type', '!=', 'submitted_final') + ] + + + + + + + + + + Authorizer: Own Assessments + + [('authorizer_id', '=', user.partner_id.id)] + + + + + + + + + Sales Rep: Own Assessments + + [('sales_rep_id', '=', user.id)] + + + + + + + + + + Authorizer Portal: Assigned Orders + + [('x_fc_authorizer_id', '=', user.partner_id.id)] + + + + + + + + + + Technician Portal: Assigned Deliveries + + [('x_fc_delivery_technician_ids', 'in', [user.id])] + + + + + + + + + + Sales Rep Portal: Own Orders + + [('user_id', '=', user.id)] + + + + + + + + diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/static/description/icon.png b/fusion_authorizer_portal/fusion_authorizer_portal/static/description/icon.png new file mode 100644 index 0000000..796bac9 Binary files /dev/null and b/fusion_authorizer_portal/fusion_authorizer_portal/static/description/icon.png differ diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/static/src/css/portal_style.css b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/css/portal_style.css new file mode 100644 index 0000000..3fb49d7 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/css/portal_style.css @@ -0,0 +1,864 @@ +/* Fusion Authorizer Portal - Custom Styles */ +/* Color Scheme: Dark Blue (#1a365d, #2c5282) with Green accents (#38a169) */ + +:root { + --portal-primary: #1a365d; + --portal-primary-light: #2c5282; + --portal-accent: #38a169; + --portal-accent-light: #48bb78; + --portal-dark: #1a202c; + --portal-gray: #718096; + --portal-light: #f7fafc; +} + +/* Portal Header Styling - Only for Fusion Portal pages */ +/* Removed global navbar styling to prevent affecting other portal pages */ + +/* Card Headers with Portal Theme */ +.card-header.bg-dark { + background: linear-gradient(135deg, var(--portal-primary) 0%, var(--portal-primary-light) 100%) !important; +} + +.card-header.bg-primary { + background: var(--portal-primary-light) !important; +} + +.card-header.bg-success { + background: var(--portal-accent) !important; +} + +/* Stat Cards */ +.card.bg-primary { + background: linear-gradient(135deg, var(--portal-primary) 0%, var(--portal-primary-light) 100%) !important; +} + +.card.bg-success { + background: linear-gradient(135deg, var(--portal-accent) 0%, var(--portal-accent-light) 100%) !important; +} + +/* Table Styling */ +.table-dark th { + background: var(--portal-primary) !important; +} + +.table-success th { + background: var(--portal-accent) !important; + color: white !important; +} + +.table-info th { + background: var(--portal-primary-light) !important; + color: white !important; +} + +/* Badges */ +.badge.bg-primary { + background: var(--portal-primary-light) !important; +} + +.badge.bg-success { + background: var(--portal-accent) !important; +} + +/* Buttons */ +.btn-primary { + background: var(--portal-primary-light) !important; + border-color: var(--portal-primary-light) !important; +} + +.btn-primary:hover { + background: var(--portal-primary) !important; + border-color: var(--portal-primary) !important; +} + +.btn-success { + background: var(--portal-accent) !important; + border-color: var(--portal-accent) !important; +} + +.btn-success:hover { + background: var(--portal-accent-light) !important; + border-color: var(--portal-accent-light) !important; +} + +.btn-outline-primary { + color: var(--portal-primary-light) !important; + border-color: var(--portal-primary-light) !important; +} + +.btn-outline-primary:hover { + background: var(--portal-primary-light) !important; + color: white !important; +} + +/* Search Box Styling */ +#portal-search-input { + border-radius: 25px 0 0 25px; + padding-left: 20px; +} + +#portal-search-input:focus { + border-color: var(--portal-primary-light); + box-shadow: 0 0 0 0.2rem rgba(44, 82, 130, 0.25); +} + +/* Case List Row Hover */ +.table-hover tbody tr:hover { + background-color: rgba(44, 82, 130, 0.1); +} + +/* Document Upload Area */ +.document-upload-area { + border: 2px dashed var(--portal-gray); + border-radius: 10px; + padding: 20px; + text-align: center; + background: var(--portal-light); + transition: all 0.3s ease; +} + +.document-upload-area:hover { + border-color: var(--portal-primary-light); + background: rgba(44, 82, 130, 0.05); +} + +/* Comment Section */ +.comment-item { + border-left: 4px solid var(--portal-primary-light); + padding-left: 15px; + margin-bottom: 15px; +} + +.comment-item .comment-author { + font-weight: 600; + color: var(--portal-primary); +} + +.comment-item .comment-date { + font-size: 0.85em; + color: var(--portal-gray); +} + +/* Signature Pad */ +.signature-pad-container { + border: 2px solid var(--portal-gray); + border-radius: 8px; + padding: 10px; + background: white; + touch-action: none; +} + +.signature-pad-container canvas { + cursor: crosshair; + width: 100%; + height: 200px; +} + +/* Progress Bar */ +.progress { + border-radius: 15px; + overflow: hidden; +} + +.progress-bar { + font-size: 0.75rem; + font-weight: 600; +} + +/* Assessment Form Cards */ +.assessment-section-card { + transition: all 0.3s ease; +} + +.assessment-section-card:hover { + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} + +/* Status Badges */ +.status-badge { + padding: 0.5em 1em; + border-radius: 20px; + font-weight: 500; +} + +.status-draft { + background: #e2e8f0; + color: #4a5568; +} + +.status-pending { + background: #faf089; + color: #744210; +} + +.status-completed { + background: #c6f6d5; + color: #276749; +} + +.status-cancelled { + background: #fed7d7; + color: #9b2c2c; +} + +/* Quick Action Buttons */ +.quick-action-btn { + min-width: 150px; + margin-bottom: 10px; +} + +/* Loading Spinner */ +.search-loading { + display: none; + position: absolute; + right: 50px; + top: 50%; + transform: translateY(-50%); +} + +.search-loading.active { + display: block; +} + +/* Mobile Responsiveness */ +@media (max-width: 768px) { + .card-header { + font-size: 0.9rem; + } + + .btn-lg { + font-size: 1rem; + padding: 0.5rem 1rem; + } + + .table-responsive { + font-size: 0.85rem; + } + + .signature-pad-container canvas { + height: 150px; + } +} + +/* Animation for Search Results */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.search-result-row { + animation: fadeIn 0.3s ease; +} + +/* Highlight matching text */ +.search-highlight { + background-color: #faf089; + padding: 0 2px; + border-radius: 2px; +} + +/* ======================================== + EXPRESS ASSESSMENT FORM STYLES + ======================================== */ + +.assessment-express-form .form-label { + color: #333; + font-size: 0.95rem; +} + +.assessment-express-form .form-label.fw-bold { + font-weight: 600 !important; +} + +.assessment-express-form .form-control, +.assessment-express-form .form-select { + border-radius: 6px; + border-color: #dee2e6; + padding: 0.625rem 0.875rem; +} + +.assessment-express-form .form-control:focus, +.assessment-express-form .form-select:focus { + border-color: #2e7aad; + box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.15); +} + +.assessment-express-form .form-select-lg { + padding: 0.75rem 1rem; + font-size: 1.1rem; +} + +/* Input Group with Inch suffix */ +.assessment-express-form .input-group-text { + background-color: #f8f9fa; + border-color: #dee2e6; + color: #6c757d; + font-weight: 500; +} + +/* Checkbox and Radio Styling */ +.assessment-express-form .form-check { + padding-left: 1.75rem; + margin-bottom: 0.5rem; +} + +.assessment-express-form .form-check-input { + width: 1.15rem; + height: 1.15rem; + margin-top: 0.15rem; + margin-left: -1.75rem; +} + +.assessment-express-form .form-check-input:checked { + background-color: #2e7aad; + border-color: #2e7aad; +} + +.assessment-express-form .form-check-label { + color: #333; + cursor: pointer; +} + +/* Equipment Form Sections */ +.assessment-express-form .equipment-form h2 { + color: #1a1a1a; + font-size: 1.5rem; + letter-spacing: 1px; +} + +/* Card Styling */ +.assessment-express-form .card { + border: none; + border-radius: 12px; +} + +.assessment-express-form .card-header.bg-primary { + background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important; + border-radius: 12px 12px 0 0; +} + +.assessment-express-form .card-body { + padding: 2rem; +} + +.assessment-express-form .card-footer { + border-top: 1px solid #e9ecef; + padding: 1.25rem 2rem; +} + +/* Button Styling */ +.assessment-express-form .btn-primary { + background: #2e7aad !important; + border-color: #4361ee !important; + padding: 0.75rem 2rem; + font-weight: 600; + border-radius: 6px; +} + +.assessment-express-form .btn-primary:hover { + background: #3451d1 !important; + border-color: #3451d1 !important; +} + +.assessment-express-form .btn-success { + background: #10b981 !important; + border-color: #10b981 !important; + padding: 0.75rem 2rem; + font-weight: 600; + border-radius: 6px; +} + +.assessment-express-form .btn-success:hover { + background: #059669 !important; + border-color: #059669 !important; +} + +.assessment-express-form .btn-outline-secondary { + border-width: 2px; + font-weight: 500; +} + +/* Progress Bar */ +.assessment-express-form .progress { + height: 8px; + background-color: #e9ecef; +} + +.assessment-express-form .progress-bar { + background: linear-gradient(90deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); +} + +/* Section Separators */ +.assessment-express-form hr { + border-color: #e9ecef; + opacity: 1; +} + +/* Required Field Indicator */ +.assessment-express-form .text-danger { + color: #dc3545 !important; +} + +/* Section Headers within form */ +.assessment-express-form h5.fw-bold { + color: #374151; + border-bottom: 2px solid #2e7aad; + padding-bottom: 0.5rem; + display: inline-block; +} + +/* New Assessment Card on Portal Home */ +.portal-new-assessment-card { + background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.portal-new-assessment-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3) !important; +} + +.portal-new-assessment-card .card-body { + background: transparent !important; +} + +.portal-new-assessment-card h5, +.portal-new-assessment-card small { + color: #fff !important; +} + +.portal-new-assessment-card .icon-circle { + width: 50px; + height: 50px; + background: rgba(255,255,255,0.25) !important; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.portal-new-assessment-card .icon-circle i { + color: #fff !important; + font-size: 1.25rem; +} + +/* Authorizer Portal Card on Portal Home */ +.portal-authorizer-card { + background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.portal-authorizer-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 30px rgba(46, 122, 173, 0.3) !important; +} + +.portal-authorizer-card .card-body { + background: transparent !important; +} + +.portal-authorizer-card h5, +.portal-authorizer-card small { + color: #fff !important; +} + +.portal-authorizer-card .icon-circle { + width: 50px; + height: 50px; + background: rgba(255,255,255,0.25) !important; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.portal-authorizer-card .icon-circle i { + color: #fff !important; + font-size: 1.25rem; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .assessment-express-form .card-body { + padding: 1.25rem; + } + + .assessment-express-form .d-flex.flex-wrap.gap-4 { + gap: 0.5rem !important; + } + + .assessment-express-form .row { + margin-left: -0.5rem; + margin-right: -0.5rem; + } + + .assessment-express-form .col-md-4, + .assessment-express-form .col-md-6 { + padding-left: 0.5rem; + padding-right: 0.5rem; + } +} + +/* ================================================================== */ +/* AUTHORIZER DASHBOARD - MOBILE-FIRST REDESIGN */ +/* ================================================================== */ + +.auth-dash { + background: #f8f9fb; + min-height: 80vh; +} + +/* Content Area */ +.auth-dash-content { + padding-top: 24px; + padding-bottom: 40px; +} + +/* Welcome Header */ +.auth-dash-header { + background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); + border-radius: 16px; + margin-bottom: 24px; + overflow: hidden; +} + +.auth-dash-header-inner { + padding: 28px 30px 24px; +} + +.auth-dash-greeting { + color: #fff; + font-size: 24px; + font-weight: 700; + margin: 0 0 4px 0; + letter-spacing: -0.3px; +} + +.auth-dash-subtitle { + color: rgba(255,255,255,0.85); + font-size: 14px; + margin: 0; + font-weight: 400; +} + +/* ---- Action Tiles ---- */ +.auth-dash-tiles { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 28px; +} + +.auth-tile { + display: flex; + align-items: center; + background: #fff; + border-radius: 14px; + padding: 18px 20px; + text-decoration: none !important; + color: #333 !important; + border: 1px solid #e8ecf1; + box-shadow: 0 1px 4px rgba(0,0,0,0.04); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; + min-height: 72px; +} + +.auth-tile:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0,0,0,0.08); + border-color: #d0d5dd; +} + +.auth-tile:active { + transform: scale(0.98); +} + +.auth-tile-icon { + width: 48px; + height: 48px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 20px; + color: #fff; + margin-right: 16px; +} + +.auth-tile-cases .auth-tile-icon { + background: linear-gradient(135deg, #2e7aad, #1a6b9a); +} + +.auth-tile-assessments .auth-tile-icon { + background: linear-gradient(135deg, #5ba848, #4a9e3f); +} + +.auth-tile-new .auth-tile-icon { + background: linear-gradient(135deg, #3a8fb7, #2e7aad); +} + +.auth-tile-info { + flex: 1; + min-width: 0; +} + +.auth-tile-title { + font-size: 16px; + font-weight: 600; + color: #1a1a2e; + line-height: 1.3; +} + +.auth-tile-desc { + font-size: 13px; + color: #8b95a5; + line-height: 1.3; + margin-top: 3px; +} + +.auth-tile-badge .badge { + background: #eef1f7; + color: #3949ab; + font-size: 15px; + font-weight: 700; + padding: 5px 14px; + border-radius: 20px; + margin-right: 10px; +} + +.auth-tile-arrow { + color: #c5ccd6; + font-size: 14px; + flex-shrink: 0; +} + +/* ---- Sections ---- */ +.auth-dash-section { + background: #fff; + border-radius: 14px; + overflow: hidden; + margin-bottom: 20px; + border: 1px solid #e8ecf1; + box-shadow: 0 1px 4px rgba(0,0,0,0.04); +} + +.auth-section-header { + padding: 16px 20px; + font-size: 15px; + font-weight: 600; + color: #444; + border-bottom: 1px solid #f0f2f5; + display: flex; + align-items: center; +} + +.auth-section-attention { + color: #c0392b; + background: #fef5f5; + border-bottom-color: #fce4e4; +} + +.auth-section-pending { + color: #d97706; + background: #fef9f0; + border-bottom-color: #fdecd0; +} + +/* ---- Case List Items ---- */ +.auth-case-list { + padding: 0; +} + +.auth-case-item { + display: flex; + align-items: center; + padding: 16px 20px; + text-decoration: none !important; + color: inherit !important; + border-bottom: 1px solid #f3f4f6; + transition: background 0.1s ease; + cursor: pointer; +} + +.auth-case-item:last-child { + border-bottom: none; +} + +.auth-case-item:hover { + background: #f9fafb; +} + +.auth-case-item:active { + background: #f0f2f5; +} + +.auth-case-attention { + border-left: 3px solid #e74c3c; +} + +.auth-case-main { + flex: 1; + min-width: 0; +} + +.auth-case-client { + font-size: 15px; + font-weight: 600; + color: #1a1a2e; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.auth-case-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 5px; + align-items: center; +} + +.auth-case-ref { + font-size: 12px; + color: #9ca3af; + font-weight: 500; +} + +.auth-case-type { + font-size: 11px; + background: #e3f2fd; + color: #1565c0; + padding: 2px 10px; + border-radius: 10px; + font-weight: 500; + text-transform: uppercase; +} + +.auth-case-status { + font-size: 11px; + background: #f3e5f5; + color: #7b1fa2; + padding: 2px 10px; + border-radius: 10px; + font-weight: 500; +} + +.badge-attention { + font-size: 11px; + background: #fce4ec; + color: #c62828; + padding: 2px 10px; + border-radius: 10px; + font-weight: 600; +} + +.auth-case-date { + font-size: 12px; + color: #9ca3af; +} + +.auth-case-arrow { + color: #c5ccd6; + font-size: 14px; + flex-shrink: 0; + margin-left: 12px; +} + +/* ---- Empty State ---- */ +.auth-empty-state { + text-align: center; + padding: 60px 20px; + color: #aaa; +} + +.auth-empty-state i { + font-size: 48px; + margin-bottom: 16px; + display: block; +} + +.auth-empty-state h5 { + color: #666; + margin-bottom: 8px; +} + +/* ---- Desktop Enhancements ---- */ +@media (min-width: 768px) { + .auth-dash-header-inner { + padding: 32px 36px 28px; + } + + .auth-dash-greeting { + font-size: 28px; + } + + .auth-dash-tiles { + flex-direction: row; + gap: 16px; + } + + .auth-tile { + flex: 1; + padding: 20px 22px; + } + + .auth-dash-content { + padding-top: 28px; + } +} + +/* ---- Mobile Optimizations ---- */ +@media (max-width: 767px) { + .auth-dash-content { + padding-left: 12px; + padding-right: 12px; + padding-top: 16px; + } + + .auth-dash-header { + border-radius: 0; + margin-left: -12px; + margin-right: -12px; + margin-top: -24px; + margin-bottom: 20px; + } + + .auth-dash-header-inner { + padding: 22px 20px 20px; + } + + .auth-dash-greeting { + font-size: 20px; + } + + .auth-dash-subtitle { + font-size: 13px; + } + + .auth-tile { + padding: 16px 18px; + min-height: 66px; + } + + .auth-tile-icon { + width: 44px; + height: 44px; + font-size: 18px; + margin-right: 14px; + } + + .auth-case-item { + padding: 14px 18px; + } + + .auth-section-header { + padding: 14px 18px; + } +} diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/static/src/css/technician_portal.css b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/css/technician_portal.css new file mode 100644 index 0000000..18139f2 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/css/technician_portal.css @@ -0,0 +1,540 @@ +/* ========================================================================== + Fusion Technician Portal - Mobile-First Styles (v2) + ========================================================================== */ + +/* ---- Base & Mobile First ---- */ +.tech-portal { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + max-width: 640px; + margin: 0 auto; +} + +/* ---- Quick Stats Bar (Dashboard) ---- */ +.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; + border-radius: 12px; + text-align: center; + color: #fff; + font-weight: 600; +} +.tech-stat-card .stat-number { + font-size: 1.5rem; + line-height: 1.2; +} +.tech-stat-card .stat-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.9; +} +.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); } + +/* ---- Hero Card (Dashboard Current Task) ---- */ +.tech-hero-card { + border: none; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 4px 20px rgba(0,0,0,0.08); + margin-bottom: 1.5rem; +} +.tech-hero-card .card-header { + background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); + color: #fff; + padding: 1rem 1.25rem; + border: none; +} +.tech-hero-card .card-header h5 { + color: #fff; + margin: 0; +} +.tech-hero-card .card-body { + padding: 1.25rem; +} +.tech-hero-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.85; + margin-bottom: 0.15rem; +} + +/* ---- Timeline (Dashboard) ---- */ +.tech-timeline { + position: relative; + padding-left: 2rem; +} +.tech-timeline::before { + content: ''; + position: absolute; + left: 0.75rem; + top: 0; + bottom: 0; + width: 2px; + background: #dee2e6; +} + +.tech-timeline-item { + position: relative; + margin-bottom: 1rem; + padding-bottom: 0.5rem; +} +.tech-timeline-dot { + position: absolute; + left: -1.55rem; + top: 0.35rem; + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid #fff; + box-shadow: 0 0 0 2px #dee2e6; + z-index: 1; +} +.tech-timeline-dot.status-scheduled { background: #6c757d; box-shadow: 0 0 0 2px #6c757d; } +.tech-timeline-dot.status-en_route { background: #3498db; box-shadow: 0 0 0 2px #3498db; } +.tech-timeline-dot.status-in_progress { background: #f39c12; box-shadow: 0 0 0 2px #f39c12; animation: pulse-dot 1.5s infinite; } +.tech-timeline-dot.status-completed { background: #27ae60; box-shadow: 0 0 0 2px #27ae60; } +.tech-timeline-dot.status-cancelled { background: #e74c3c; box-shadow: 0 0 0 2px #e74c3c; } + +@keyframes pulse-dot { + 0%, 100% { box-shadow: 0 0 0 2px #f39c12; } + 50% { box-shadow: 0 0 0 6px rgba(243, 156, 18, 0.3); } +} + +.tech-timeline-card { + border: 1px solid #e9ecef; + border-radius: 12px; + padding: 0.875rem 1rem; + background: #fff; + transition: box-shadow 0.2s, transform 0.15s; + text-decoration: none !important; + color: inherit !important; + display: block; +} +.tech-timeline-card:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.08); + transform: translateY(-1px); +} +.tech-timeline-card.active { + border-color: #f39c12; + border-width: 2px; + box-shadow: 0 4px 16px rgba(243, 156, 18, 0.15); +} + +.tech-timeline-time { + font-size: 0.85rem; + font-weight: 600; + color: #495057; +} +.tech-timeline-title { + font-size: 0.95rem; + font-weight: 600; + color: #212529; + margin: 0.15rem 0; +} +.tech-timeline-meta { + font-size: 0.8rem; + color: #6c757d; +} + +/* Travel indicator between tasks */ +.tech-travel-indicator { + padding: 0.35rem 0 0.35rem 0; + margin-left: -0.2rem; + font-size: 0.75rem; + color: #8e44ad; +} + +/* ---- Task Type Badges ---- */ +.tech-badge { + display: inline-block; + padding: 0.2rem 0.5rem; + border-radius: 6px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.tech-badge-delivery { background: #d4edda; color: #155724; } +.tech-badge-repair { background: #fff3cd; color: #856404; } +.tech-badge-pickup { background: #cce5ff; color: #004085; } +.tech-badge-troubleshoot { background: #f8d7da; color: #721c24; } +.tech-badge-assessment { background: #e2e3e5; color: #383d41; } +.tech-badge-installation { background: #d1ecf1; color: #0c5460; } +.tech-badge-maintenance { background: #e8daef; color: #6c3483; } +.tech-badge-other { background: #e9ecef; color: #495057; } + +/* Status badges */ +.tech-status-badge { + display: inline-block; + padding: 0.25rem 0.6rem; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; +} +.tech-status-scheduled { background: #e9ecef; color: #495057; } +.tech-status-en_route { background: #cce5ff; color: #004085; } +.tech-status-in_progress { background: #fff3cd; color: #856404; } +.tech-status-completed { background: #d4edda; color: #155724; } +.tech-status-cancelled { background: #f8d7da; color: #721c24; } + +/* ========================================================================== + Task Detail Page - v2 Redesign + ========================================================================== */ + +/* ---- Back button ---- */ +.tech-back-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--o-main-bg-color, #f8f9fa); + color: var(--o-main-text-color, #495057); + text-decoration: none !important; + transition: background 0.15s; + border: 1px solid var(--o-main-border-color, #dee2e6); +} +.tech-back-btn:hover { + background: var(--o-main-border-color, #dee2e6); +} + +/* ---- Task Hero Header ---- */ +.tech-task-hero { + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--o-main-border-color, #eee); +} + +/* ---- Quick Actions Row ---- */ +.tech-quick-actions { + display: flex; + gap: 0.75rem; + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + padding: 0.25rem 0; +} +.tech-quick-actions::-webkit-scrollbar { display: none; } + +.tech-quick-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + min-width: 68px; + padding: 0.6rem 0.5rem; + border-radius: 14px; + background: var(--o-main-bg-color, #f8f9fa); + border: 1px solid var(--o-main-border-color, #e9ecef); + color: var(--o-main-text-color, #495057) !important; + text-decoration: none !important; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + transition: all 0.15s; + flex-shrink: 0; +} +.tech-quick-btn i { + font-size: 1.15rem; + color: #3498db; +} +.tech-quick-btn:hover { + background: #e3f2fd; + border-color: #90caf9; +} +.tech-quick-btn:active { + transform: scale(0.95); +} + +/* ---- Card (unified style for all sections) ---- */ +.tech-card { + background: var(--o-main-card-bg, #fff); + border: 1px solid var(--o-main-border-color, #e9ecef); + border-radius: 14px; + padding: 1rem; +} +.tech-card-success { + border-color: #c3e6cb; + background: color-mix(in srgb, #d4edda 30%, var(--o-main-card-bg, #fff)); +} + +/* ---- Card icon (left gutter icon) ---- */ +.tech-card-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 12px; + font-size: 1rem; + margin-right: 0.75rem; + flex-shrink: 0; +} + +/* ---- Equipment highlight tag ---- */ +.tech-equipment-tag { + background: color-mix(in srgb, #ffeeba 25%, var(--o-main-card-bg, #fff)); + border: 1px solid #ffeeba; + border-radius: 10px; + padding: 0.75rem; +} + +/* ---- Action Buttons (Large Touch Targets) ---- */ +.tech-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 48px; + padding: 0.75rem 1.5rem; + border-radius: 14px; + font-weight: 600; + font-size: 0.95rem; + border: none; + cursor: pointer; + transition: all 0.15s; + text-decoration: none !important; +} +.tech-action-btn:active { transform: scale(0.97); } + +.tech-btn-navigate { + background: #3498db; + color: #fff !important; +} +.tech-btn-navigate:hover { background: #2980b9; color: #fff !important; } + +.tech-btn-start { + background: #27ae60; + color: #fff !important; +} +.tech-btn-start:hover { background: #219a52; color: #fff !important; } + +.tech-btn-complete { + background: #f39c12; + color: #fff !important; +} +.tech-btn-complete:hover { background: #e67e22; color: #fff !important; } + +.tech-btn-call { + background: #9b59b6; + color: #fff !important; +} +.tech-btn-call:hover { background: #8e44ad; color: #fff !important; } + +.tech-btn-enroute { + background: #2980b9; + color: #fff !important; +} +.tech-btn-enroute:hover { background: #2471a3; color: #fff !important; } + +/* ---- Bottom Action Bar (Fixed on mobile) ---- */ +.tech-bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--o-main-card-bg, #fff); + border-top: 1px solid var(--o-main-border-color, #dee2e6); + padding: 0.75rem 1rem; + padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px)); + z-index: 1050; + display: flex; + gap: 0.5rem; + box-shadow: 0 -4px 20px rgba(0,0,0,0.08); +} +.tech-bottom-bar .tech-action-btn { + flex: 1; +} + +/* Padding to prevent content being hidden behind fixed bar */ +.has-bottom-bar { + padding-bottom: 5rem; +} + +/* ---- Completion Overlay ---- */ +.tech-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.7); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 9999; + align-items: center; + justify-content: center; +} +.tech-overlay-card { + background: var(--o-main-card-bg, #fff); + border-radius: 20px; + padding: 2rem; + max-width: 400px; + width: 90%; + text-align: center; + animation: slideUp 0.3s ease; +} +.tech-overlay-icon { + font-size: 3rem; + margin-bottom: 1rem; +} +@keyframes slideUp { + from { opacity: 0; transform: translateY(30px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ---- Voice Recording UI ---- */ +.tech-voice-recorder { + border: 2px dashed var(--o-main-border-color, #dee2e6); + border-radius: 16px; + padding: 1.5rem 1rem; + text-align: center; + transition: all 0.3s; +} +.tech-voice-recorder.recording { + border-color: #e74c3c; + background: rgba(231, 76, 60, 0.04); +} + +.tech-record-btn { + width: 64px; + height: 64px; + border-radius: 50%; + border: none; + background: #e74c3c; + color: #fff; + font-size: 1.3rem; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + justify-content: center; +} +.tech-record-btn:hover { transform: scale(1.05); } +.tech-record-btn:active { transform: scale(0.95); } +.tech-record-btn.recording { + animation: pulse-record 1.5s infinite; +} + +@keyframes pulse-record { + 0%, 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.4); } + 50% { box-shadow: 0 0 0 15px rgba(231, 76, 60, 0); } +} + +.tech-record-timer { + font-size: 1.25rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + margin-top: 0.5rem; + color: #e74c3c; +} + +/* ---- Tomorrow Prep ---- */ +.tech-prep-card { + border: 1px solid #e9ecef; + border-radius: 12px; + padding: 1rem; + margin-bottom: 0.75rem; + background: #fff; +} +.tech-prep-card .prep-time { + font-weight: 700; + font-size: 0.9rem; +} +.tech-prep-card .prep-type { + margin-left: 0.5rem; +} +.tech-prep-equipment { + background: #fff9e6; + border: 1px solid #ffeeba; + border-radius: 12px; + padding: 1rem; +} + +/* ---- Responsive: Desktop enhancements ---- */ +@media (min-width: 768px) { + .tech-stats-bar { + gap: 1rem; + } + .tech-stat-card { + min-width: 130px; + padding: 1rem 1.5rem; + } + .tech-stat-card .stat-number { + font-size: 2rem; + } + .tech-bottom-bar { + position: static; + box-shadow: none; + border: none; + padding: 0; + margin-top: 1rem; + } + .has-bottom-bar { + padding-bottom: 0; + } + .tech-timeline { + padding-left: 3rem; + } + .tech-timeline::before { + left: 1.25rem; + } + .tech-timeline-dot { + left: -2.05rem; + } + .tech-quick-btn { + min-width: 80px; + padding: 0.75rem 0.75rem; + } +} + +/* ---- Legacy detail section support ---- */ +.tech-detail-section { + background: var(--o-main-card-bg, #fff); + border: 1px solid var(--o-main-border-color, #e9ecef); + border-radius: 14px; + padding: 1rem; + margin-bottom: 1rem; +} +.tech-detail-section h6 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #6c757d; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--o-main-border-color, #f1f3f5); +} +.tech-detail-row { + display: flex; + justify-content: space-between; + padding: 0.3rem 0; +} +.tech-detail-label { + font-weight: 500; + color: var(--o-main-text-color, #495057); + font-size: 0.9rem; +} +.tech-detail-value { + color: var(--o-main-text-color, #212529); + font-size: 0.9rem; + text-align: right; +} diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/assessment_form.js b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/assessment_form.js new file mode 100644 index 0000000..cd72cb1 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/assessment_form.js @@ -0,0 +1,109 @@ +/** + * Fusion Authorizer Portal - Assessment Form + */ + +odoo.define('fusion_authorizer_portal.assessment_form', function (require) { + 'use strict'; + + var publicWidget = require('web.public.widget'); + + publicWidget.registry.AssessmentForm = publicWidget.Widget.extend({ + selector: '#assessment-form', + events: { + 'change input, change select, change textarea': '_onFieldChange', + 'submit': '_onSubmit', + }, + + init: function () { + this._super.apply(this, arguments); + this.hasUnsavedChanges = false; + }, + + start: function () { + this._super.apply(this, arguments); + this._initializeForm(); + return Promise.resolve(); + }, + + _initializeForm: function () { + var self = this; + + // Warn before leaving with unsaved changes + window.addEventListener('beforeunload', function (e) { + if (self.hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = ''; + return ''; + } + }); + + // Auto-fill full name from first + last name + var firstNameInput = this.el.querySelector('[name="client_first_name"]'); + var lastNameInput = this.el.querySelector('[name="client_last_name"]'); + var fullNameInput = this.el.querySelector('[name="client_name"]'); + + if (firstNameInput && lastNameInput && fullNameInput) { + var updateFullName = function () { + var first = firstNameInput.value.trim(); + var last = lastNameInput.value.trim(); + if (first || last) { + fullNameInput.value = (first + ' ' + last).trim(); + } + }; + + firstNameInput.addEventListener('blur', updateFullName); + lastNameInput.addEventListener('blur', updateFullName); + } + + // Number input validation + var numberInputs = this.el.querySelectorAll('input[type="number"]'); + numberInputs.forEach(function (input) { + input.addEventListener('input', function () { + var value = parseFloat(this.value); + var min = parseFloat(this.min) || 0; + var max = parseFloat(this.max) || 9999; + + if (value < min) this.value = min; + if (value > max) this.value = max; + }); + }); + }, + + _onFieldChange: function (ev) { + this.hasUnsavedChanges = true; + + // Visual feedback that form has changes + var saveBtn = this.el.querySelector('button[value="save"]'); + if (saveBtn) { + saveBtn.classList.add('btn-warning'); + saveBtn.classList.remove('btn-primary'); + } + }, + + _onSubmit: function (ev) { + // Validate required fields + var requiredFields = this.el.querySelectorAll('[required]'); + var isValid = true; + + requiredFields.forEach(function (field) { + if (!field.value.trim()) { + field.classList.add('is-invalid'); + isValid = false; + } else { + field.classList.remove('is-invalid'); + } + }); + + if (!isValid) { + ev.preventDefault(); + alert('Please fill in all required fields.'); + return false; + } + + this.hasUnsavedChanges = false; + return true; + } + }); + + return publicWidget.registry.AssessmentForm; +}); diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js new file mode 100644 index 0000000..6802395 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js @@ -0,0 +1,37 @@ +/** @odoo-module **/ +// Fusion Authorizer Portal - Message Authorizer Chatter Button +// Copyright 2026 Nexa Systems Inc. +// License OPL-1 +// +// Patches the Chatter component to add a "Message Authorizer" button +// that opens the mail composer targeted at the assigned authorizer. + +import { Chatter } from "@mail/chatter/web_portal/chatter"; +import { patch } from "@web/core/utils/patch"; +import { useService } from "@web/core/utils/hooks"; + +patch(Chatter.prototype, { + setup() { + super.setup(...arguments); + this._fapActionService = useService("action"); + this._fapOrm = useService("orm"); + }, + + async onClickMessageAuthorizer() { + const thread = this.state.thread; + if (!thread || thread.model !== "sale.order") return; + + try { + const result = await this._fapOrm.call( + "sale.order", + "action_message_authorizer", + [thread.id], + ); + if (result && result.type === "ir.actions.act_window") { + this._fapActionService.doAction(result); + } + } catch (e) { + console.warn("Message Authorizer action failed:", e); + } + }, +}); diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/loaner_portal.js b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/loaner_portal.js new file mode 100644 index 0000000..3c25a84 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/loaner_portal.js @@ -0,0 +1,478 @@ +/** @odoo-module **/ + +import publicWidget from "@web/legacy/js/public/public_widget"; + +publicWidget.registry.LoanerPortal = publicWidget.Widget.extend({ + selector: '#loanerSection, #btn_checkout_loaner, .btn-loaner-return', + + start: function () { + this._super.apply(this, arguments); + this._allProducts = []; + this._initLoanerSection(); + this._initCheckoutButton(); + this._initReturnButtons(); + this._initModal(); + }, + + // ===================================================================== + // MODAL: Initialize and wire up the loaner checkout modal + // ===================================================================== + _initModal: function () { + var self = this; + var modal = document.getElementById('loanerCheckoutModal'); + if (!modal) return; + + var categorySelect = document.getElementById('modal_category_id'); + var productSelect = document.getElementById('modal_product_id'); + var lotSelect = document.getElementById('modal_lot_id'); + var loanDays = document.getElementById('modal_loan_days'); + var btnCheckout = document.getElementById('modal_btn_checkout'); + var btnCreateProduct = document.getElementById('modal_btn_create_product'); + var newCategorySelect = document.getElementById('modal_new_category_id'); + var createResult = document.getElementById('modal_create_result'); + + // Load categories when modal opens + modal.addEventListener('show.bs.modal', function () { + self._loadCategories(categorySelect, newCategorySelect); + self._loadProducts(null, productSelect, lotSelect); + }); + + // Category change -> filter products + if (categorySelect) { + categorySelect.addEventListener('change', function () { + var catId = this.value ? parseInt(this.value) : null; + self._filterProducts(catId, productSelect, lotSelect); + }); + } + + // Product change -> filter lots + if (productSelect) { + productSelect.addEventListener('change', function () { + var prodId = this.value ? parseInt(this.value) : null; + self._filterLots(prodId, lotSelect, loanDays); + }); + } + + // Quick Create Product + if (btnCreateProduct) { + btnCreateProduct.addEventListener('click', function () { + var name = document.getElementById('modal_new_product_name').value.trim(); + var serial = document.getElementById('modal_new_serial').value.trim(); + var catId = newCategorySelect ? newCategorySelect.value : ''; + + if (!name || !serial) { + alert('Please enter both product name and serial number.'); + return; + } + + btnCreateProduct.disabled = true; + btnCreateProduct.innerHTML = ' Creating...'; + + self._rpc('/my/loaner/create-product', { + product_name: name, + serial_number: serial, + category_id: catId || null, + }).then(function (result) { + if (result.success) { + // Add to product dropdown + var opt = document.createElement('option'); + opt.value = result.product_id; + opt.text = result.product_name; + opt.selected = true; + productSelect.appendChild(opt); + + // Add to lots + lotSelect.innerHTML = ''; + var lotOpt = document.createElement('option'); + lotOpt.value = result.lot_id; + lotOpt.text = result.lot_name; + lotOpt.selected = true; + lotSelect.appendChild(lotOpt); + + // Add to internal data + self._allProducts.push({ + id: result.product_id, + name: result.product_name, + category_id: catId ? parseInt(catId) : null, + period_days: 7, + lots: [{ id: result.lot_id, name: result.lot_name }], + }); + + if (createResult) { + createResult.style.display = ''; + createResult.innerHTML = '
    "' + result.product_name + '" (S/N: ' + result.lot_name + ') created!
    '; + } + + // Clear fields + document.getElementById('modal_new_product_name').value = ''; + document.getElementById('modal_new_serial').value = ''; + } else { + if (createResult) { + createResult.style.display = ''; + createResult.innerHTML = '
    ' + (result.error || 'Error') + '
    '; + } + } + btnCreateProduct.disabled = false; + btnCreateProduct.innerHTML = ' Create Product'; + }); + }); + } + + // Checkout button + if (btnCheckout) { + btnCheckout.addEventListener('click', function () { + var productId = productSelect.value ? parseInt(productSelect.value) : null; + var lotId = lotSelect.value ? parseInt(lotSelect.value) : null; + var days = parseInt(loanDays.value) || 7; + var orderId = document.getElementById('modal_order_id').value; + var clientId = document.getElementById('modal_client_id').value; + + if (!productId) { + alert('Please select a product.'); + return; + } + + btnCheckout.disabled = true; + btnCheckout.innerHTML = ' Processing...'; + + self._rpc('/my/loaner/checkout', { + product_id: productId, + lot_id: lotId, + sale_order_id: orderId ? parseInt(orderId) : null, + client_id: clientId ? parseInt(clientId) : null, + loaner_period_days: days, + checkout_condition: 'good', + checkout_notes: '', + }).then(function (result) { + if (result.success) { + self._hideModal(modal); + alert(result.message); + location.reload(); + } else { + alert('Error: ' + (result.error || 'Unknown')); + btnCheckout.disabled = false; + btnCheckout.innerHTML = ' Checkout Loaner'; + } + }); + }); + } + }, + + _loadCategories: function (categorySelect, newCategorySelect) { + this._rpc('/my/loaner/categories', {}).then(function (categories) { + categories = categories || []; + // Main category dropdown + if (categorySelect) { + categorySelect.innerHTML = ''; + categories.forEach(function (c) { + var opt = document.createElement('option'); + opt.value = c.id; + opt.text = c.name; + categorySelect.appendChild(opt); + }); + } + // Quick create category dropdown + if (newCategorySelect) { + newCategorySelect.innerHTML = ''; + categories.forEach(function (c) { + var opt = document.createElement('option'); + opt.value = c.id; + opt.text = c.name; + newCategorySelect.appendChild(opt); + }); + } + }); + }, + + _loadProducts: function (categoryId, productSelect, lotSelect) { + var self = this; + var params = {}; + if (categoryId) params.category_id = categoryId; + + this._rpc('/my/loaner/products', params).then(function (products) { + self._allProducts = products || []; + self._renderProducts(self._allProducts, productSelect); + if (lotSelect) lotSelect.innerHTML = ''; + }); + }, + + _filterProducts: function (categoryId, productSelect, lotSelect) { + var filtered = this._allProducts; + if (categoryId) { + filtered = this._allProducts.filter(function (p) { return p.category_id === categoryId; }); + } + this._renderProducts(filtered, productSelect); + if (lotSelect) lotSelect.innerHTML = ''; + }, + + _renderProducts: function (products, productSelect) { + if (!productSelect) return; + productSelect.innerHTML = ''; + products.forEach(function (p) { + var opt = document.createElement('option'); + opt.value = p.id; + opt.text = p.name + ' (' + p.lots.length + ' avail)'; + productSelect.appendChild(opt); + }); + }, + + _filterLots: function (productId, lotSelect, loanDays) { + if (!lotSelect) return; + lotSelect.innerHTML = ''; + if (!productId) return; + var product = this._allProducts.find(function (p) { return p.id === productId; }); + if (product) { + product.lots.forEach(function (lot) { + var opt = document.createElement('option'); + opt.value = lot.id; + opt.text = lot.name; + lotSelect.appendChild(opt); + }); + if (loanDays && product.period_days) { + loanDays.value = product.period_days; + } + } + }, + + // ===================================================================== + // CHECKOUT BUTTON: Opens the modal + // ===================================================================== + _initCheckoutButton: function () { + var self = this; + var btns = document.querySelectorAll('#btn_checkout_loaner'); + btns.forEach(function (btn) { + btn.addEventListener('click', function () { + var orderId = btn.dataset.orderId || ''; + var clientId = btn.dataset.clientId || ''; + + // Set context in modal + var modalOrderId = document.getElementById('modal_order_id'); + var modalClientId = document.getElementById('modal_client_id'); + if (modalOrderId) modalOrderId.value = orderId; + if (modalClientId) modalClientId.value = clientId; + + // Show modal + var modal = document.getElementById('loanerCheckoutModal'); + self._showModal(modal); + }); + }); + }, + + // ===================================================================== + // RETURN BUTTONS + // ===================================================================== + _initReturnButtons: function () { + var self = this; + var returnModal = document.getElementById('loanerReturnModal'); + if (!returnModal) return; + + var btnSubmitReturn = document.getElementById('return_modal_btn_submit'); + + document.querySelectorAll('.btn-loaner-return').forEach(function (btn) { + btn.addEventListener('click', function () { + var checkoutId = parseInt(btn.dataset.checkoutId); + var productName = btn.dataset.productName || 'Loaner'; + + // Set modal values + document.getElementById('return_modal_checkout_id').value = checkoutId; + document.getElementById('return_modal_product_name').textContent = productName; + document.getElementById('return_modal_condition').value = 'good'; + document.getElementById('return_modal_notes').value = ''; + + // Load locations + var locSelect = document.getElementById('return_modal_location_id'); + locSelect.innerHTML = ''; + self._rpc('/my/loaner/locations', {}).then(function (locations) { + locations = locations || []; + locSelect.innerHTML = ''; + locations.forEach(function (l) { + var opt = document.createElement('option'); + opt.value = l.id; + opt.text = l.name; + locSelect.appendChild(opt); + }); + }); + + // Show modal + self._showModal(returnModal); + }); + }); + + // Submit return + if (btnSubmitReturn) { + btnSubmitReturn.addEventListener('click', function () { + var checkoutId = parseInt(document.getElementById('return_modal_checkout_id').value); + var condition = document.getElementById('return_modal_condition').value; + var notes = document.getElementById('return_modal_notes').value; + var locationId = document.getElementById('return_modal_location_id').value; + + btnSubmitReturn.disabled = true; + btnSubmitReturn.innerHTML = ' Processing...'; + + self._rpc('/my/loaner/return', { + checkout_id: checkoutId, + return_condition: condition, + return_notes: notes, + return_location_id: locationId ? parseInt(locationId) : null, + }).then(function (result) { + if (result.success) { + self._hideModal(returnModal); + alert(result.message); + location.reload(); + } else { + alert('Error: ' + (result.error || 'Unknown')); + btnSubmitReturn.disabled = false; + btnSubmitReturn.innerHTML = ' Confirm Return'; + } + }); + }); + } + }, + + // ===================================================================== + // EXPRESS ASSESSMENT: Loaner Section + // ===================================================================== + _initLoanerSection: function () { + var self = this; + var loanerSection = document.getElementById('loanerSection'); + if (!loanerSection) return; + + var productSelect = document.getElementById('loaner_product_id'); + var lotSelect = document.getElementById('loaner_lot_id'); + var periodInput = document.getElementById('loaner_period_days'); + var checkoutFlag = document.getElementById('loaner_checkout'); + var existingFields = document.getElementById('loaner_existing_fields'); + var newFields = document.getElementById('loaner_new_fields'); + var modeRadios = document.querySelectorAll('input[name="loaner_mode"]'); + var btnCreate = document.getElementById('btn_create_loaner_product'); + var createResult = document.getElementById('loaner_create_result'); + var productsData = []; + + loanerSection.addEventListener('show.bs.collapse', function () { + if (productSelect && productSelect.options.length <= 1) { + self._rpc('/my/loaner/products', {}).then(function (data) { + productsData = data || []; + productSelect.innerHTML = ''; + productsData.forEach(function (p) { + var opt = document.createElement('option'); + opt.value = p.id; + opt.text = p.name + ' (' + p.lots.length + ' avail)'; + productSelect.appendChild(opt); + }); + }); + } + }); + + loanerSection.addEventListener('shown.bs.collapse', function () { + if (checkoutFlag) checkoutFlag.value = '1'; + }); + loanerSection.addEventListener('hidden.bs.collapse', function () { + if (checkoutFlag) checkoutFlag.value = '0'; + }); + + modeRadios.forEach(function (radio) { + radio.addEventListener('change', function () { + if (this.value === 'existing') { + if (existingFields) existingFields.style.display = ''; + if (newFields) newFields.style.display = 'none'; + } else { + if (existingFields) existingFields.style.display = 'none'; + if (newFields) newFields.style.display = ''; + } + }); + }); + + if (productSelect) { + productSelect.addEventListener('change', function () { + lotSelect.innerHTML = ''; + var product = productsData.find(function (p) { return p.id === parseInt(productSelect.value); }); + if (product) { + product.lots.forEach(function (lot) { + var opt = document.createElement('option'); + opt.value = lot.id; + opt.text = lot.name; + lotSelect.appendChild(opt); + }); + if (periodInput && product.period_days) periodInput.value = product.period_days; + } + }); + } + + if (btnCreate) { + btnCreate.addEventListener('click', function () { + var name = document.getElementById('loaner_new_product_name').value.trim(); + var serial = document.getElementById('loaner_new_serial').value.trim(); + if (!name || !serial) { alert('Enter both name and serial.'); return; } + btnCreate.disabled = true; + btnCreate.innerHTML = ' Creating...'; + + self._rpc('/my/loaner/create-product', { + product_name: name, serial_number: serial, + }).then(function (result) { + if (result.success) { + var opt = document.createElement('option'); + opt.value = result.product_id; + opt.text = result.product_name; + opt.selected = true; + productSelect.appendChild(opt); + lotSelect.innerHTML = ''; + var lotOpt = document.createElement('option'); + lotOpt.value = result.lot_id; + lotOpt.text = result.lot_name; + lotOpt.selected = true; + lotSelect.appendChild(lotOpt); + document.getElementById('loaner_existing').checked = true; + if (existingFields) existingFields.style.display = ''; + if (newFields) newFields.style.display = 'none'; + if (createResult) { + createResult.style.display = ''; + createResult.innerHTML = '
    Created "' + result.product_name + '" (S/N: ' + result.lot_name + ')
    '; + } + } else { + if (createResult) { + createResult.style.display = ''; + createResult.innerHTML = '
    ' + (result.error || 'Error') + '
    '; + } + } + btnCreate.disabled = false; + btnCreate.innerHTML = ' Create Product'; + }); + }); + } + }, + + // ===================================================================== + // HELPERS + // ===================================================================== + _rpc: function (url, params) { + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'call', id: 1, params: params }), + }).then(function (r) { return r.json(); }).then(function (d) { return d.result; }); + }, + + _showModal: function (modalEl) { + if (!modalEl) return; + var Modal = window.bootstrap ? window.bootstrap.Modal : null; + if (Modal) { + var inst = Modal.getOrCreateInstance ? Modal.getOrCreateInstance(modalEl) : new Modal(modalEl); + inst.show(); + } else if (window.$ || window.jQuery) { + (window.$ || window.jQuery)(modalEl).modal('show'); + } + }, + + _hideModal: function (modalEl) { + if (!modalEl) return; + try { + var Modal = window.bootstrap ? window.bootstrap.Modal : null; + if (Modal && Modal.getInstance) { + var inst = Modal.getInstance(modalEl); + if (inst) inst.hide(); + } else if (window.$ || window.jQuery) { + (window.$ || window.jQuery)(modalEl).modal('hide'); + } + } catch (e) { /* non-blocking */ } + }, +}); diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/pdf_field_editor.js b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/pdf_field_editor.js new file mode 100644 index 0000000..b8855e9 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/pdf_field_editor.js @@ -0,0 +1,716 @@ +/** + * Fusion PDF Field Position Editor + * + * Features: + * - Drag field types from sidebar palette onto PDF to create new fields + * - Drag existing fields to reposition them + * - Resize handles on each field (bottom-right corner) + * - Click to select and edit properties in right panel + * - Percentage-based positions (0.0-1.0), same as Odoo Sign module + * - Auto-save on every drag/resize + */ + +document.addEventListener('DOMContentLoaded', function () { + 'use strict'; + + var editor = document.getElementById('pdf_field_editor'); + if (!editor) return; + + var templateId = parseInt(editor.dataset.templateId); + var pageCount = parseInt(editor.dataset.pageCount) || 1; + var templateCategory = editor.dataset.category || 'other'; + var currentPage = 1; + var fields = {}; + var selectedFieldId = null; + var fieldCounter = 0; + var container = document.getElementById('pdf_canvas_container'); + var pageImage = document.getElementById('pdf_page_image'); + + // ================================================================ + // Colors per field type + // ================================================================ + + // ================================================================ + // Available data keys, organized by template category + // ================================================================ + + var COMMON_KEYS = [ + { group: 'Client Info', keys: [ + { key: 'client_last_name', label: 'Last Name' }, + { key: 'client_first_name', label: 'First Name' }, + { key: 'client_middle_name', label: 'Middle Name' }, + { key: 'client_name', label: 'Full Name' }, + { key: 'client_street', label: 'Street' }, + { key: 'client_unit', label: 'Unit/Apt' }, + { key: 'client_city', label: 'City' }, + { key: 'client_state', label: 'Province' }, + { key: 'client_postal_code', label: 'Postal Code' }, + { key: 'client_phone', label: 'Phone' }, + { key: 'client_email', label: 'Email' }, + ]}, + ]; + + var CATEGORY_KEYS = { + adp: [ + { group: 'ADP - Client Details', keys: [ + { key: 'client_health_card', label: 'Health Card Number' }, + { key: 'client_health_card_version', label: 'Health Card Version' }, + { key: 'client_weight', label: 'Weight (lbs)' }, + ]}, + { group: 'ADP - Client Type', keys: [ + { key: 'client_type_reg', label: 'REG Checkbox' }, + { key: 'client_type_ods', label: 'ODS Checkbox' }, + { key: 'client_type_acs', label: 'ACS Checkbox' }, + { key: 'client_type_owp', label: 'OWP Checkbox' }, + ]}, + { group: 'ADP - Consent', keys: [ + { key: 'consent_applicant', label: 'Applicant Checkbox' }, + { key: 'consent_agent', label: 'Agent Checkbox' }, + { key: 'consent_date', label: 'Consent Date' }, + ]}, + { group: 'ADP - Agent Relationship', keys: [ + { key: 'agent_rel_spouse', label: 'Spouse Checkbox' }, + { key: 'agent_rel_parent', label: 'Parent Checkbox' }, + { key: 'agent_rel_child', label: 'Child Checkbox' }, + { key: 'agent_rel_poa', label: 'POA Checkbox' }, + { key: 'agent_rel_guardian', label: 'Guardian Checkbox' }, + ]}, + { group: 'ADP - Agent Info', keys: [ + { key: 'agent_last_name', label: 'Agent Last Name' }, + { key: 'agent_first_name', label: 'Agent First Name' }, + { key: 'agent_middle_initial', label: 'Agent Middle Initial' }, + { key: 'agent_unit', label: 'Agent Unit' }, + { key: 'agent_street_number', label: 'Agent Street No.' }, + { key: 'agent_street_name', label: 'Agent Street Name' }, + { key: 'agent_city', label: 'Agent City' }, + { key: 'agent_province', label: 'Agent Province' }, + { key: 'agent_postal_code', label: 'Agent Postal Code' }, + { key: 'agent_home_phone', label: 'Agent Home Phone' }, + { key: 'agent_business_phone', label: 'Agent Business Phone' }, + { key: 'agent_phone_ext', label: 'Agent Phone Ext' }, + ]}, + { group: 'ADP - Equipment', keys: [ + { key: 'equipment_type', label: 'Equipment Type' }, + { key: 'seat_width', label: 'Seat Width' }, + { key: 'seat_depth', label: 'Seat Depth' }, + { key: 'seat_to_floor_height', label: 'Seat to Floor Height' }, + { key: 'back_height', label: 'Back Height' }, + { key: 'legrest_length', label: 'Legrest Length' }, + { key: 'cane_height', label: 'Cane Height' }, + ]}, + { group: 'ADP - Dates', keys: [ + { key: 'assessment_start_date', label: 'Assessment Start Date' }, + { key: 'assessment_end_date', label: 'Assessment End Date' }, + { key: 'claim_authorization_date', label: 'Authorization Date' }, + ]}, + { group: 'ADP - Authorizer', keys: [ + { key: 'authorizer_name', label: 'Authorizer Name' }, + { key: 'authorizer_phone', label: 'Authorizer Phone' }, + { key: 'authorizer_email', label: 'Authorizer Email' }, + ]}, + { group: 'ADP - Signatures', keys: [ + { key: 'signature_page_11', label: 'Page 11 Signature' }, + { key: 'signature_page_12', label: 'Page 12 Signature' }, + ]}, + { group: 'ADP - Other', keys: [ + { key: 'reference', label: 'Assessment Reference' }, + { key: 'reason_for_application', label: 'Reason for Application' }, + ]}, + ], + + odsp: [ + { group: 'ODSP - Signing Fields', keys: [ + { key: 'sa_client_name', label: 'Client Name (signing)' }, + { key: 'sa_sign_date', label: 'Signing Date' }, + { key: 'sa_signature', label: 'Client Signature' }, + ]}, + { group: 'ODSP - Client Details', keys: [ + { key: 'client_health_card', label: 'Health Card Number' }, + { key: 'client_health_card_version', label: 'Health Card Version' }, + { key: 'client_weight', label: 'Weight (lbs)' }, + ]}, + { group: 'ODSP - Dates', keys: [ + { key: 'assessment_start_date', label: 'Assessment Start Date' }, + { key: 'assessment_end_date', label: 'Assessment End Date' }, + ]}, + { group: 'ODSP - Authorizer', keys: [ + { key: 'authorizer_name', label: 'Authorizer Name' }, + { key: 'authorizer_phone', label: 'Authorizer Phone' }, + { key: 'authorizer_email', label: 'Authorizer Email' }, + ]}, + ], + + mod: [ + { group: 'MOD - Dates', keys: [ + { key: 'assessment_start_date', label: 'Assessment Start Date' }, + { key: 'assessment_end_date', label: 'Assessment End Date' }, + ]}, + { group: 'MOD - Authorizer', keys: [ + { key: 'authorizer_name', label: 'Authorizer Name' }, + { key: 'authorizer_phone', label: 'Authorizer Phone' }, + { key: 'authorizer_email', label: 'Authorizer Email' }, + ]}, + ], + + hardship: [ + { group: 'Hardship - Dates', keys: [ + { key: 'assessment_start_date', label: 'Assessment Start Date' }, + { key: 'assessment_end_date', label: 'Assessment End Date' }, + ]}, + { group: 'Hardship - Authorizer', keys: [ + { key: 'authorizer_name', label: 'Authorizer Name' }, + { key: 'authorizer_phone', label: 'Authorizer Phone' }, + { key: 'authorizer_email', label: 'Authorizer Email' }, + ]}, + ], + }; + + var DATA_KEYS = COMMON_KEYS.concat(CATEGORY_KEYS[templateCategory] || []); + + // Build a flat lookup: key -> label + var KEY_LABELS = {}; + DATA_KEYS.forEach(function (g) { + g.keys.forEach(function (k) { KEY_LABELS[k.key] = k.label; }); + }); + + // Build '; + DATA_KEYS.forEach(function (g) { + html += ''; + g.keys.forEach(function (k) { + html += ''; + }); + html += ''; + }); + return html; + } + + var COLORS = { + text: { bg: 'rgba(52,152,219,0.25)', border: '#3498db' }, + checkbox: { bg: 'rgba(46,204,113,0.25)', border: '#2ecc71' }, + date: { bg: 'rgba(230,126,34,0.25)', border: '#e67e22' }, + signature: { bg: 'rgba(155,89,182,0.25)', border: '#9b59b6' }, + }; + + var DEFAULT_SIZES = { + text: { w: 0.150, h: 0.018 }, + checkbox: { w: 0.018, h: 0.018 }, + date: { w: 0.120, h: 0.018 }, + signature: { w: 0.200, h: 0.050 }, + }; + + // ================================================================ + // JSONRPC helper + // ================================================================ + + function jsonrpc(url, params) { + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'call', id: 1, params: params || {} }) + }).then(function (r) { return r.json(); }) + .then(function (d) { + if (d.error) { console.error('RPC error', d.error); return null; } + return d.result; + }); + } + + // ================================================================ + // Init + // ================================================================ + + function init() { + loadFields(); + setupPageNavigation(); + setupPaletteDrag(); + setupContainerDrop(); + setupPreviewButton(); + buildDataKeysSidebar(); + + // Prevent the image from intercepting drag events + if (pageImage) { + pageImage.style.pointerEvents = 'none'; + } + // Also prevent any child (except field markers) from blocking drops + container.querySelectorAll('#no_preview_placeholder').forEach(function (el) { + el.style.pointerEvents = 'none'; + }); + } + + function buildDataKeysSidebar() { + var list = document.getElementById('dataKeysList'); + if (!list) return; + var html = ''; + DATA_KEYS.forEach(function (g) { + html += '
    ' + g.group + ':
    '; + g.keys.forEach(function (k) { + html += '' + k.key + ''; + }); + }); + list.innerHTML = html; + } + + // ================================================================ + // Load fields + // ================================================================ + + function loadFields() { + jsonrpc('/fusion/pdf-editor/fields', { template_id: templateId }).then(function (result) { + if (!result) return; + fields = {}; + result.forEach(function (f) { fields[f.id] = f; fieldCounter++; }); + renderFieldsForPage(currentPage); + }); + } + + // ================================================================ + // Render fields on current page + // ================================================================ + + function renderFieldsForPage(page) { + container.querySelectorAll('.pdf-field-marker').forEach(function (el) { el.remove(); }); + Object.values(fields).forEach(function (f) { + if (f.page === page) renderFieldMarker(f); + }); + updateFieldCount(); + } + + function renderFieldMarker(field) { + var c = COLORS[field.field_type] || COLORS.text; + + var marker = document.createElement('div'); + marker.className = 'pdf-field-marker'; + marker.dataset.fieldId = field.id; + marker.setAttribute('draggable', 'true'); + + Object.assign(marker.style, { + position: 'absolute', + left: (field.pos_x * 100) + '%', + top: (field.pos_y * 100) + '%', + width: (field.width * 100) + '%', + height: Math.max(field.height * 100, 1.5) + '%', + backgroundColor: c.bg, + border: '2px solid ' + c.border, + borderRadius: '3px', + cursor: 'move', + display: 'flex', + alignItems: 'center', + fontSize: '10px', + color: '#333', + fontWeight: '600', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + padding: '0 4px', + zIndex: 10, + boxSizing: 'border-box', + userSelect: 'none', + }); + + // Label text + var label = document.createElement('span'); + label.style.pointerEvents = 'none'; + label.style.flex = '1'; + label.style.overflow = 'hidden'; + label.style.textOverflow = 'ellipsis'; + label.textContent = field.label || field.name; + marker.appendChild(label); + + // Resize handle (bottom-right corner) + var handle = document.createElement('div'); + Object.assign(handle.style, { + position: 'absolute', + right: '0', + bottom: '0', + width: '10px', + height: '10px', + backgroundColor: c.border, + cursor: 'nwse-resize', + borderRadius: '2px 0 2px 0', + opacity: '0.7', + }); + handle.className = 'resize-handle'; + handle.addEventListener('mousedown', function (e) { + e.preventDefault(); + e.stopPropagation(); + startResize(field.id, e); + }); + marker.appendChild(handle); + + // Tooltip + marker.title = (field.label || field.name) + '\nKey: ' + (field.field_key || 'unmapped') + '\nType: ' + field.field_type; + + // Drag to reposition + marker.addEventListener('dragstart', function (e) { onFieldDragStart(e, field.id); }); + marker.addEventListener('dragend', function (e) { e.target.style.opacity = ''; }); + + // Click to select + marker.addEventListener('click', function (e) { + e.stopPropagation(); + selectField(field.id); + }); + + container.appendChild(marker); + + // Highlight if selected + if (field.id === selectedFieldId) { + marker.style.boxShadow = '0 0 0 3px #007bff'; + marker.style.zIndex = '20'; + } + } + + // ================================================================ + // Drag existing fields to reposition + // ================================================================ + + var dragOffsetX = 0, dragOffsetY = 0; + var dragFieldId = null; + var dragSource = null; // 'field' or 'palette' + var dragFieldType = null; + + function onFieldDragStart(e, fieldId) { + dragSource = 'field'; + dragFieldId = fieldId; + var rect = e.target.getBoundingClientRect(); + dragOffsetX = e.clientX - rect.left; + dragOffsetY = e.clientY - rect.top; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', 'field'); + requestAnimationFrame(function () { e.target.style.opacity = '0.4'; }); + } + + // ================================================================ + // Drag from palette to create new field + // ================================================================ + + function setupPaletteDrag() { + document.querySelectorAll('.pdf-palette-item').forEach(function (item) { + item.addEventListener('dragstart', function (e) { + dragSource = 'palette'; + dragFieldType = e.currentTarget.dataset.fieldType; + dragFieldId = null; + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('text/plain', 'palette'); + e.currentTarget.style.opacity = '0.5'; + }); + item.addEventListener('dragend', function (e) { + e.currentTarget.style.opacity = ''; + }); + }); + } + + // ================================================================ + // Drop handler on PDF container + // ================================================================ + + function setupContainerDrop() { + // Must preventDefault on dragover for drop to fire + container.addEventListener('dragover', function (e) { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = (dragSource === 'palette') ? 'copy' : 'move'; + }); + + container.addEventListener('dragenter', function (e) { + e.preventDefault(); + e.stopPropagation(); + }); + + container.addEventListener('drop', function (e) { + e.preventDefault(); + e.stopPropagation(); + + // Use the container rect as the reference area + // (the image has pointer-events:none, so we use the container which matches its size) + var rect = container.getBoundingClientRect(); + + if (dragSource === 'palette' && dragFieldType) { + // ---- CREATE new field at drop position ---- + var defaults = DEFAULT_SIZES[dragFieldType] || DEFAULT_SIZES.text; + var posX = (e.clientX - rect.left) / rect.width; + var posY = (e.clientY - rect.top) / rect.height; + posX = normalize(posX, defaults.w); + posY = normalize(posY, defaults.h); + posX = round3(posX); + posY = round3(posY); + + fieldCounter++; + var autoName = dragFieldType + '_' + fieldCounter; + + var newField = { + template_id: templateId, + name: autoName, + label: autoName, + field_type: dragFieldType, + field_key: autoName, + page: currentPage, + pos_x: posX, + pos_y: posY, + width: defaults.w, + height: defaults.h, + font_size: 10, + }; + + jsonrpc('/fusion/pdf-editor/create-field', newField).then(function (res) { + if (res && res.id) { + newField.id = res.id; + fields[res.id] = newField; + renderFieldsForPage(currentPage); + selectField(res.id); + } + }); + + } else if (dragSource === 'field' && dragFieldId && fields[dragFieldId]) { + // ---- MOVE existing field ---- + var field = fields[dragFieldId]; + var posX = (e.clientX - rect.left - dragOffsetX) / rect.width; + var posY = (e.clientY - rect.top - dragOffsetY) / rect.height; + posX = normalize(posX, field.width); + posY = normalize(posY, field.height); + posX = round3(posX); + posY = round3(posY); + + field.pos_x = posX; + field.pos_y = posY; + saveField(field.id, { pos_x: posX, pos_y: posY }); + renderFieldsForPage(currentPage); + selectField(field.id); + } + + dragSource = null; + dragFieldId = null; + dragFieldType = null; + }); + } + + // ================================================================ + // Resize handles + // ================================================================ + + function startResize(fieldId, startEvent) { + var field = fields[fieldId]; + if (!field) return; + + var imgRect = container.getBoundingClientRect(); + var startX = startEvent.clientX; + var startY = startEvent.clientY; + var startW = field.width; + var startH = field.height; + var marker = container.querySelector('[data-field-id="' + fieldId + '"]'); + + function onMove(e) { + var dx = (e.clientX - startX) / imgRect.width; + var dy = (e.clientY - startY) / imgRect.height; + var newW = Math.max(startW + dx, 0.010); + var newH = Math.max(startH + dy, 0.005); + + // Clamp to page bounds + if (field.pos_x + newW > 1.0) newW = 1.0 - field.pos_x; + if (field.pos_y + newH > 1.0) newH = 1.0 - field.pos_y; + + field.width = round3(newW); + field.height = round3(newH); + + if (marker) { + marker.style.width = (field.width * 100) + '%'; + marker.style.height = Math.max(field.height * 100, 1.5) + '%'; + } + } + + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + saveField(fieldId, { width: field.width, height: field.height }); + renderFieldsForPage(currentPage); + selectField(fieldId); + } + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + } + + // ================================================================ + // Select field and show properties + // ================================================================ + + function selectField(fieldId) { + selectedFieldId = fieldId; + var field = fields[fieldId]; + if (!field) return; + + // Re-render to update highlights + renderFieldsForPage(currentPage); + + var panel = document.getElementById('field_props_body'); + panel.innerHTML = '' + + '
    ' + + ' ' + + ' ' + + '
    ' + + row('Name', 'text', 'prop_name', field.name || '') + + row('Label', 'text', 'prop_label', field.label || '') + + '
    ' + + ' ' + + ' ' + + '
    ' + + '
    ' + + '
    ' + row('Font Size', 'number', 'prop_font_size', field.font_size || 10, '0.5') + '
    ' + + '
    ' + row('Page', 'number', 'prop_page', field.page || 1) + '
    ' + + '
    ' + + '
    ' + + '
    ' + row('Width', 'number', 'prop_width', field.width || 0.15, '0.005') + '
    ' + + '
    ' + row('Height', 'number', 'prop_height', field.height || 0.015, '0.005') + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + ' ' + + '
    ' + + ' ' + + ' ' + + ' ' + + '
    ' + + '
    ' + + '
    ' + + ' ' + + ' ' + + '
    '; + + // Auto-fill name and label when data key is selected + document.getElementById('prop_field_key').addEventListener('change', function () { + var selectedKey = this.value; + if (selectedKey && KEY_LABELS[selectedKey]) { + document.getElementById('prop_name').value = selectedKey; + document.getElementById('prop_label').value = KEY_LABELS[selectedKey]; + } + }); + + var alignBtns = panel.querySelectorAll('[data-align]'); + alignBtns.forEach(function (btn) { + btn.addEventListener('click', function () { + alignBtns.forEach(function (b) { b.classList.remove('active'); }); + btn.classList.add('active'); + }); + }); + + document.getElementById('btn_save_props').addEventListener('click', function () { + var keySelect = document.getElementById('prop_field_key'); + var selectedKey = keySelect ? keySelect.value : ''; + var activeAlign = panel.querySelector('[data-align].active'); + var vals = { + name: val('prop_name'), + label: val('prop_label'), + field_key: selectedKey, + field_type: val('prop_type'), + font_size: parseFloat(val('prop_font_size')) || 10, + page: parseInt(val('prop_page')) || 1, + width: parseFloat(val('prop_width')) || 0.15, + height: parseFloat(val('prop_height')) || 0.015, + text_align: activeAlign ? activeAlign.dataset.align : 'left', + }; + Object.assign(field, vals); + saveField(fieldId, vals); + renderFieldsForPage(currentPage); + selectField(fieldId); + }); + + document.getElementById('btn_delete_field').addEventListener('click', function () { + if (!confirm('Delete "' + (field.label || field.name) + '"?')) return; + jsonrpc('/fusion/pdf-editor/delete-field', { field_id: fieldId }).then(function () { + delete fields[fieldId]; + selectedFieldId = null; + renderFieldsForPage(currentPage); + panel.innerHTML = '

    Field deleted.

    '; + }); + }); + } + + // ================================================================ + // Save field to server + // ================================================================ + + function saveField(fieldId, values) { + jsonrpc('/fusion/pdf-editor/update-field', { field_id: fieldId, values: values }); + } + + // ================================================================ + // Page navigation + // ================================================================ + + function setupPageNavigation() { + var prev = document.getElementById('btn_prev_page'); + var next = document.getElementById('btn_next_page'); + if (prev) prev.addEventListener('click', function () { if (currentPage > 1) switchPage(--currentPage); }); + if (next) next.addEventListener('click', function () { if (currentPage < pageCount) switchPage(++currentPage); }); + } + + function switchPage(page) { + currentPage = page; + var d = document.getElementById('current_page_display'); + if (d) d.textContent = page; + jsonrpc('/fusion/pdf-editor/page-image', { template_id: templateId, page: page }).then(function (r) { + if (r && r.image_url && pageImage) pageImage.src = r.image_url; + renderFieldsForPage(page); + }); + } + + // ================================================================ + // Preview + // ================================================================ + + function setupPreviewButton() { + var btn = document.getElementById('btn_preview'); + if (btn) btn.addEventListener('click', function () { + window.open('/fusion/pdf-editor/preview/' + templateId, '_blank'); + }); + } + + // ================================================================ + // Helpers + // ================================================================ + + function normalize(pos, dim) { + if (pos < 0) return 0; + if (pos + dim > 1.0) return 1.0 - dim; + return pos; + } + + function round3(n) { return Math.round((n || 0) * 1000) / 1000; } + + function val(id) { var el = document.getElementById(id); return el ? el.value : ''; } + + function sel(current, option) { return current === option ? ' selected' : ''; } + + function row(label, type, id, value, step) { + return '
    ' + + '
    '; + } + + function updateFieldCount() { + var el = document.getElementById('field_count'); + if (el) el.textContent = Object.keys(fields).length; + } + + // ================================================================ + // Start + // ================================================================ + + init(); +}); diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/portal_search.js b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/portal_search.js new file mode 100644 index 0000000..7961773 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/portal_search.js @@ -0,0 +1,161 @@ +/** + * Fusion Authorizer Portal - Real-time Search + */ + +odoo.define('fusion_authorizer_portal.portal_search', function (require) { + 'use strict'; + + var publicWidget = require('web.public.widget'); + var ajax = require('web.ajax'); + + publicWidget.registry.PortalSearch = publicWidget.Widget.extend({ + selector: '#portal-search-input', + events: { + 'input': '_onSearchInput', + 'keydown': '_onKeyDown', + }, + + init: function () { + this._super.apply(this, arguments); + this.debounceTimer = null; + this.searchEndpoint = this._getSearchEndpoint(); + this.resultsContainer = null; + }, + + start: function () { + this._super.apply(this, arguments); + this.resultsContainer = document.getElementById('cases-table-body'); + return Promise.resolve(); + }, + + _getSearchEndpoint: function () { + // Determine which portal we're on + var path = window.location.pathname; + if (path.includes('/my/authorizer')) { + return '/my/authorizer/cases/search'; + } else if (path.includes('/my/sales')) { + return '/my/sales/cases/search'; + } + return null; + }, + + _onSearchInput: function (ev) { + var self = this; + var query = ev.target.value.trim(); + + clearTimeout(this.debounceTimer); + + if (query.length < 2) { + // If query is too short, reload original page + return; + } + + // Debounce - wait 250ms before searching + this.debounceTimer = setTimeout(function () { + self._performSearch(query); + }, 250); + }, + + _onKeyDown: function (ev) { + if (ev.key === 'Enter') { + ev.preventDefault(); + var query = ev.target.value.trim(); + if (query.length >= 2) { + this._performSearch(query); + } + } else if (ev.key === 'Escape') { + ev.target.value = ''; + window.location.reload(); + } + }, + + _performSearch: function (query) { + var self = this; + + if (!this.searchEndpoint) { + console.error('Search endpoint not found'); + return; + } + + // Show loading indicator + this._showLoading(true); + + ajax.jsonRpc(this.searchEndpoint, 'call', { + query: query + }).then(function (response) { + self._showLoading(false); + + if (response.error) { + console.error('Search error:', response.error); + return; + } + + self._renderResults(response.results, query); + }).catch(function (error) { + self._showLoading(false); + console.error('Search failed:', error); + }); + }, + + _showLoading: function (show) { + var spinner = document.querySelector('.search-loading'); + if (spinner) { + spinner.classList.toggle('active', show); + } + }, + + _renderResults: function (results, query) { + if (!this.resultsContainer) { + return; + } + + if (!results || results.length === 0) { + this.resultsContainer.innerHTML = ` + + + +

    No results found for "${query}"

    + + + `; + return; + } + + var html = ''; + var isAuthorizer = window.location.pathname.includes('/my/authorizer'); + var baseUrl = isAuthorizer ? '/my/authorizer/case/' : '/my/sales/case/'; + + results.forEach(function (order) { + var stateClass = 'bg-secondary'; + if (order.state === 'sent') stateClass = 'bg-primary'; + else if (order.state === 'sale') stateClass = 'bg-success'; + + html += ` + + ${self._highlightMatch(order.name, query)} + ${self._highlightMatch(order.partner_name, query)} + ${order.date_order} + ${self._highlightMatch(order.claim_number || '-', query)} + ${order.state_display} + + + View + + + + `; + }); + + this.resultsContainer.innerHTML = html; + }, + + _highlightMatch: function (text, query) { + if (!text || !query) return text || ''; + + var regex = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi'); + return text.replace(regex, '$1'); + } + }); + + return publicWidget.registry.PortalSearch; +}); diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/signature_pad.js b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/signature_pad.js new file mode 100644 index 0000000..d12bc22 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/signature_pad.js @@ -0,0 +1,167 @@ +/** + * Fusion Authorizer Portal - Signature Pad + * Touch-enabled digital signature capture + */ + +odoo.define('fusion_authorizer_portal.signature_pad', function (require) { + 'use strict'; + + var publicWidget = require('web.public.widget'); + var ajax = require('web.ajax'); + + // Signature Pad Class + var SignaturePad = function (canvas, options) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.options = Object.assign({ + strokeColor: '#000000', + strokeWidth: 2, + backgroundColor: '#ffffff' + }, options || {}); + + this.isDrawing = false; + this.lastX = 0; + this.lastY = 0; + this.points = []; + + this._initialize(); + }; + + SignaturePad.prototype = { + _initialize: function () { + var self = this; + + // Set canvas size + this._resizeCanvas(); + + // Set drawing style + this.ctx.strokeStyle = this.options.strokeColor; + this.ctx.lineWidth = this.options.strokeWidth; + this.ctx.lineCap = 'round'; + this.ctx.lineJoin = 'round'; + + // Clear with background color + this.clear(); + + // Event listeners + this.canvas.addEventListener('mousedown', this._startDrawing.bind(this)); + this.canvas.addEventListener('mousemove', this._draw.bind(this)); + this.canvas.addEventListener('mouseup', this._stopDrawing.bind(this)); + this.canvas.addEventListener('mouseout', this._stopDrawing.bind(this)); + + // Touch events + this.canvas.addEventListener('touchstart', this._startDrawing.bind(this), { passive: false }); + this.canvas.addEventListener('touchmove', this._draw.bind(this), { passive: false }); + this.canvas.addEventListener('touchend', this._stopDrawing.bind(this), { passive: false }); + this.canvas.addEventListener('touchcancel', this._stopDrawing.bind(this), { passive: false }); + + // Resize handler + window.addEventListener('resize', this._resizeCanvas.bind(this)); + }, + + _resizeCanvas: function () { + var rect = this.canvas.getBoundingClientRect(); + var ratio = window.devicePixelRatio || 1; + + this.canvas.width = rect.width * ratio; + this.canvas.height = rect.height * ratio; + + this.ctx.scale(ratio, ratio); + this.canvas.style.width = rect.width + 'px'; + this.canvas.style.height = rect.height + 'px'; + + // Restore drawing style after resize + this.ctx.strokeStyle = this.options.strokeColor; + this.ctx.lineWidth = this.options.strokeWidth; + this.ctx.lineCap = 'round'; + this.ctx.lineJoin = 'round'; + }, + + _getPos: function (e) { + var rect = this.canvas.getBoundingClientRect(); + var x, y; + + if (e.touches && e.touches.length > 0) { + x = e.touches[0].clientX - rect.left; + y = e.touches[0].clientY - rect.top; + } else { + x = e.clientX - rect.left; + y = e.clientY - rect.top; + } + + return { x: x, y: y }; + }, + + _startDrawing: function (e) { + e.preventDefault(); + this.isDrawing = true; + var pos = this._getPos(e); + this.lastX = pos.x; + this.lastY = pos.y; + this.points.push({ x: pos.x, y: pos.y, start: true }); + }, + + _draw: function (e) { + e.preventDefault(); + if (!this.isDrawing) return; + + var pos = this._getPos(e); + + this.ctx.beginPath(); + this.ctx.moveTo(this.lastX, this.lastY); + this.ctx.lineTo(pos.x, pos.y); + this.ctx.stroke(); + + this.lastX = pos.x; + this.lastY = pos.y; + this.points.push({ x: pos.x, y: pos.y }); + }, + + _stopDrawing: function (e) { + e.preventDefault(); + this.isDrawing = false; + }, + + clear: function () { + this.ctx.fillStyle = this.options.backgroundColor; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.points = []; + }, + + isEmpty: function () { + return this.points.length === 0; + }, + + toDataURL: function (type, quality) { + return this.canvas.toDataURL(type || 'image/png', quality || 1.0); + } + }; + + // Make SignaturePad available globally for inline scripts + window.SignaturePad = SignaturePad; + + // Widget for signature pads in portal + publicWidget.registry.SignaturePadWidget = publicWidget.Widget.extend({ + selector: '.signature-pad-container', + + start: function () { + this._super.apply(this, arguments); + + var canvas = this.el.querySelector('canvas'); + if (canvas) { + this.signaturePad = new SignaturePad(canvas); + } + + return Promise.resolve(); + }, + + getSignaturePad: function () { + return this.signaturePad; + } + }); + + return { + SignaturePad: SignaturePad, + Widget: publicWidget.registry.SignaturePadWidget + }; +}); diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/technician_location.js b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/technician_location.js new file mode 100644 index 0000000..25b6e82 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/technician_location.js @@ -0,0 +1,97 @@ +/** + * Technician Location Logger + * Logs GPS location every 5 minutes during working hours (9 AM - 6 PM) + * Only logs while the browser tab is visible. + */ +(function () { + 'use strict'; + + var INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + var STORE_OPEN_HOUR = 9; + var STORE_CLOSE_HOUR = 18; + var locationTimer = null; + + function isWorkingHours() { + var now = new Date(); + var hour = now.getHours(); + return hour >= STORE_OPEN_HOUR && hour < STORE_CLOSE_HOUR; + } + + function isTechnicianPortal() { + // Check if we're on a technician portal page + return window.location.pathname.indexOf('/my/technician') !== -1; + } + + function logLocation() { + if (!isWorkingHours()) { + return; + } + if (document.hidden) { + return; + } + if (!navigator.geolocation) { + return; + } + + navigator.geolocation.getCurrentPosition( + function (position) { + var data = { + jsonrpc: '2.0', + method: 'call', + params: { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: position.coords.accuracy || 0, + } + }; + 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 + }); + }, + 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 + 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 + if (locationTimer) { + clearInterval(locationTimer); + locationTimer = null; + } + } else { + // Tab visible again - log immediately and restart interval + logLocation(); + if (!locationTimer) { + locationTimer = setInterval(logLocation, INTERVAL_MS); + } + } + }); + } + + // Start when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', startLocationLogging); + } else { + startLocationLogging(); + } +})(); diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/technician_push.js b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/technician_push.js new file mode 100644 index 0000000..b4ae92d --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/technician_push.js @@ -0,0 +1,96 @@ +/** + * Fusion Technician Portal - Push Notification Registration + * Registers service worker and subscribes to push notifications. + * Include this script on technician portal pages. + */ + +(function() { + 'use strict'; + + // Only run on technician portal pages + if (!document.querySelector('.tech-portal') && !window.location.pathname.startsWith('/my/technician')) { + return; + } + + // Get VAPID public key from meta tag or page data + var vapidMeta = document.querySelector('meta[name="vapid-public-key"]'); + var vapidPublicKey = vapidMeta ? vapidMeta.content : null; + + if (!vapidPublicKey || !('serviceWorker' in navigator) || !('PushManager' in window)) { + return; + } + + function urlBase64ToUint8Array(base64String) { + var padding = '='.repeat((4 - base64String.length % 4) % 4); + var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + var rawData = window.atob(base64); + var outputArray = new Uint8Array(rawData.length); + for (var i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; + } + + async function registerPushSubscription() { + try { + // Register service worker + var registration = await navigator.serviceWorker.register( + '/fusion_authorizer_portal/static/src/js/technician_sw.js', + {scope: '/my/technician/'} + ); + + // Wait for service worker to be ready + await navigator.serviceWorker.ready; + + // Check existing subscription + var subscription = await registration.pushManager.getSubscription(); + + if (!subscription) { + // Request permission + var permission = await Notification.requestPermission(); + if (permission !== 'granted') { + console.log('[TechPush] Notification permission denied'); + return; + } + + // Subscribe + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + }); + } + + // Send subscription to server + var key = subscription.getKey('p256dh'); + var auth = subscription.getKey('auth'); + + var response = await fetch('/my/technician/push/subscribe', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'call', + params: { + endpoint: subscription.endpoint, + p256dh: btoa(String.fromCharCode.apply(null, new Uint8Array(key))), + auth: btoa(String.fromCharCode.apply(null, new Uint8Array(auth))), + } + }), + }); + + var data = await response.json(); + if (data.result && data.result.success) { + console.log('[TechPush] Push subscription registered successfully'); + } + } catch (error) { + console.warn('[TechPush] Push registration failed:', error); + } + } + + // Register after page load + if (document.readyState === 'complete') { + registerPushSubscription(); + } else { + window.addEventListener('load', registerPushSubscription); + } +})(); diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/technician_sw.js b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/technician_sw.js new file mode 100644 index 0000000..d18a7a5 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/js/technician_sw.js @@ -0,0 +1,77 @@ +/** + * Fusion Technician Portal - Service Worker for Push Notifications + * Handles push events and notification clicks. + */ + +self.addEventListener('push', function(event) { + if (!event.data) return; + + var data; + try { + data = event.data.json(); + } catch (e) { + data = {title: 'New Notification', body: event.data.text()}; + } + + var options = { + body: data.body || '', + icon: '/fusion_authorizer_portal/static/description/icon.png', + badge: '/fusion_authorizer_portal/static/description/icon.png', + tag: 'tech-task-' + (data.task_id || 'general'), + renotify: true, + data: { + url: data.url || '/my/technician', + taskId: data.task_id, + taskType: data.task_type, + }, + actions: [], + }; + + // Add contextual actions based on task type + if (data.url) { + options.actions.push({action: 'view', title: 'View Task'}); + } + if (data.task_type === 'delivery' || data.task_type === 'repair') { + options.actions.push({action: 'navigate', title: 'Navigate'}); + } + + event.waitUntil( + self.registration.showNotification(data.title || 'Fusion Technician', options) + ); +}); + +self.addEventListener('notificationclick', function(event) { + event.notification.close(); + + var url = '/my/technician'; + if (event.notification.data && event.notification.data.url) { + url = event.notification.data.url; + } + + if (event.action === 'navigate' && event.notification.data && event.notification.data.taskId) { + // Open Google Maps for the task (will redirect through portal) + url = '/my/technician/task/' + event.notification.data.taskId; + } + + event.waitUntil( + clients.matchAll({type: 'window', includeUncontrolled: true}).then(function(clientList) { + // Focus existing window if open + for (var i = 0; i < clientList.length; i++) { + var client = clientList[i]; + if (client.url.indexOf('/my/technician') !== -1 && 'focus' in client) { + client.navigate(url); + return client.focus(); + } + } + // Open new window + if (clients.openWindow) { + return clients.openWindow(url); + } + }) + ); +}); + +// Keep service worker alive +self.addEventListener('activate', function(event) { + event.waitUntil(self.clients.claim()); +}); diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/static/src/xml/chatter_message_authorizer.xml b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/xml/chatter_message_authorizer.xml new file mode 100644 index 0000000..953e437 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/static/src/xml/chatter_message_authorizer.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/utils/__init__.py b/fusion_authorizer_portal/fusion_authorizer_portal/utils/__init__.py new file mode 100644 index 0000000..e01b420 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/utils/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import pdf_filler diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/utils/pdf_filler.py b/fusion_authorizer_portal/fusion_authorizer_portal/utils/pdf_filler.py new file mode 100644 index 0000000..feaca15 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/utils/pdf_filler.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# Fusion PDF Template Filler +# Generic utility for filling any PDF template with data overlays. +# Uses the same pattern as Odoo Enterprise Sign module (sign/utils/pdf_handling.py): +# - Read original PDF page dimensions from mediaBox +# - Create reportlab Canvas overlay at the same page size +# - Convert percentage positions (0.0-1.0) to absolute PDF coordinates +# - Merge overlay onto original page via mergePage() + +import logging +from io import BytesIO + +from reportlab.pdfgen import canvas +from reportlab.lib.utils import ImageReader + +from odoo.tools.pdf import PdfFileReader, PdfFileWriter + +_logger = logging.getLogger(__name__) + + +class PDFTemplateFiller: + """Generic PDF template filler. Works with any template, any number of pages.""" + + @staticmethod + def fill_template(template_pdf_bytes, fields_by_page, context, signatures=None): + """Fill a PDF template by overlaying text/checkmarks/signatures at configured positions. + + Args: + template_pdf_bytes: bytes of the original PDF + fields_by_page: {page_num: [field_dicts]} where page_num is 1-based + Each field_dict has: field_key, pos_x, pos_y, width, height, + field_type, font_size, font_name + context: flat dict of {field_key: value} with all available data + signatures: dict of {field_key: binary_png} for signature image fields + + Returns: + bytes of the filled PDF (all pages preserved) + """ + if signatures is None: + signatures = {} + + try: + original = PdfFileReader(BytesIO(template_pdf_bytes)) + except Exception as e: + _logger.error("Failed to read template PDF: %s", e) + raise + + output = PdfFileWriter() + num_pages = original.getNumPages() + + 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()) + + 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)) + + for field in fields: + PDFTemplateFiller._draw_field( + c, field, context, signatures, page_w, page_h + ) + + c.save() + overlay_buf.seek(0) + + # Merge overlay onto original page (same as sign module) + overlay_pdf = PdfFileReader(overlay_buf) + page.mergePage(overlay_pdf.getPage(0)) + + output.addPage(page) + + result = BytesIO() + output.write(result) + return result.getvalue() + + @staticmethod + def _draw_field(c, field, context, signatures, page_w, page_h): + """Draw a single field onto the reportlab canvas. + + Args: + c: reportlab Canvas + field: dict with field_key, pos_x, pos_y, width, height, field_type, etc. + context: data context dict + signatures: dict of {field_key: binary} for signature fields + page_w: page width in PDF points + page_h: page height in PDF points + """ + field_key = field.get('field_key') or field.get('field_name', '') + field_type = field.get('field_type', 'text') + value = context.get(field_key, field.get('default_value', '')) + + 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 + + font_name = field.get('font_name', 'Helvetica') + font_size = field.get('font_size', 10.0) + + if field_type in ('text', 'date'): + c.setFont(font_name, font_size) + text_val = str(value) + field_h = field.get('height', 0.018) * page_h + text_y = abs_y - field_h + (field_h - font_size) / 2 + align = field.get('text_align', 'left') + if align == 'center': + center_x = abs_x + (field.get('width', 0.15) * page_w) / 2 + c.drawCentredString(center_x, text_y, text_val) + elif align == 'right': + right_x = abs_x + field.get('width', 0.15) * page_w + c.drawRightString(right_x, text_y, text_val) + else: + c.drawString(abs_x, text_y, text_val) + + elif field_type == 'checkbox': + if value: + c.setFont('ZapfDingbats', font_size) + 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') + + elif field_type == 'signature': + sig_data = signatures.get(field_key) + if sig_data: + try: + img = ImageReader(BytesIO(sig_data)) + sig_w = field.get('width', 0.15) * page_w + sig_h = field.get('height', 0.05) * page_h + # Draw signature image (position from top, so adjust Y) + c.drawImage( + img, abs_x, abs_y - sig_h, + width=sig_w, height=sig_h, + mask='auto', + ) + except Exception as e: + _logger.warning("Failed to draw signature for %s: %s", field_key, e) diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/views/assessment_views.xml b/fusion_authorizer_portal/fusion_authorizer_portal/views/assessment_views.xml new file mode 100644 index 0000000..afcf13f --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/views/assessment_views.xml @@ -0,0 +1,258 @@ + + + + + + fusion.assessment.tree + fusion.assessment + + + + + + + + + + + + + + + + + fusion.assessment.form + fusion.assessment + +
    +
    +
    + +
    + + +
    + +
    +

    + +

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + +
    +
    +
    +
    + + + + fusion.assessment.search + fusion.assessment + + + + + + + + + + + + + + + + + + + + + + + + + + Assessments + fusion.assessment + list,form + + {'search_default_my_assessments': 1} + +

    + Create your first assessment +

    +

    + Assessments are used to record wheelchair specifications and client needs. + Once completed, they will create a draft sale order for review. +

    +
    +
    + + + + + + +
    diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/views/pdf_template_views.xml b/fusion_authorizer_portal/fusion_authorizer_portal/views/pdf_template_views.xml new file mode 100644 index 0000000..b387c01 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/views/pdf_template_views.xml @@ -0,0 +1,171 @@ + + + + + + + + fusion.pdf.template.form + fusion.pdf.template + +
    +
    +
    + +
    +

    + +

    +
    + + + + + + + + + + + + + + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    + + + + + + fusion.pdf.template.list + fusion.pdf.template + + + + + + + + + + + + + + + + + fusion.pdf.template.search + fusion.pdf.template + + + + + + + + + + + + + + + + + + PDF Templates + fusion.pdf.template + list,form + + {'search_default_active_templates': 1} + +

    + Create your first PDF template +

    +

    + Upload a funding agency's PDF form, position fields on it using the + visual editor, and generate filled PDFs automatically from assessment data. +

    +
    +
    + + + + + + + +
    diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_accessibility_forms.xml b/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_accessibility_forms.xml new file mode 100644 index 0000000..6e389ae --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_accessibility_forms.xml @@ -0,0 +1,1222 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_accessibility_templates.xml b/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_accessibility_templates.xml new file mode 100644 index 0000000..89de1c1 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_accessibility_templates.xml @@ -0,0 +1,712 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_assessment_express.xml b/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_assessment_express.xml new file mode 100644 index 0000000..d2a625d --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_assessment_express.xml @@ -0,0 +1,1622 @@ + + + + + + diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_book_assessment.xml b/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_book_assessment.xml new file mode 100644 index 0000000..c3cdef0 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_book_assessment.xml @@ -0,0 +1,153 @@ + + + + + diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_pdf_editor.xml b/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_pdf_editor.xml new file mode 100644 index 0000000..4988196 --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/views/portal_pdf_editor.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/views/res_partner_views.xml b/fusion_authorizer_portal/fusion_authorizer_portal/views/res_partner_views.xml new file mode 100644 index 0000000..be9835a --- /dev/null +++ b/fusion_authorizer_portal/fusion_authorizer_portal/views/res_partner_views.xml @@ -0,0 +1,123 @@ + + + + + + res.partner.form.portal.roles + res.partner + + 50 + + + + + + +
    +
    Client: ${task._clientName}
    +
    Type: ${task._typeLbl}
    +
    Technician: ${task._techName}
    +
    Date: ${task.scheduled_date || ""}
    +
    Time: ${task._timeRange}
    + ${task.address_display ? `
    Address: ${task.address_display}
    ` : ""} + ${task.travel_time_minutes ? `
    Travel: ${task.travel_time_minutes} min
    ` : ""} +
    +
    + + + Navigate → + +
    + `; + this.infoWindow.setContent(html); + this.infoWindow.open(this.map, marker); + } + + // ── Sidebar actions ───────────────────────────────────────────── + toggleSidebar() { + this.state.sidebarOpen = !this.state.sidebarOpen; + // Trigger map resize after CSS transition + if (this.map) { + setTimeout(() => google.maps.event.trigger(this.map, "resize"), 320); + } + } + + toggleGroup(groupKey) { + this.state.collapsedGroups[groupKey] = !this.state.collapsedGroups[groupKey]; + } + + isGroupCollapsed(groupKey) { + return !!this.state.collapsedGroups[groupKey]; + } + + focusTask(taskId) { + this.state.activeTaskId = taskId; + const marker = this.taskMarkerMap[taskId]; + if (marker && this.map) { + this.map.panTo(marker.getPosition()); + this.map.setZoom(15); + // Find the task data + for (const g of this.state.groups) { + for (const t of g.tasks) { + if (t.id === taskId) { + this._openTaskPopup(t, marker); + return; + } + } + } + } + } + + // ── Day filter toggle ──────────────────────────────────────────── + toggleDayFilter(groupKey) { + this.state.visibleGroups[groupKey] = !this.state.visibleGroups[groupKey]; + this._renderMarkers(); + } + + isGroupVisible(groupKey) { + return this.state.visibleGroups[groupKey] !== false; + } + + showAllDays() { + for (const k of Object.keys(this.state.visibleGroups)) { + this.state.visibleGroups[k] = true; + } + this._renderMarkers(); + } + + showTodayOnly() { + for (const k of Object.keys(this.state.visibleGroups)) { + this.state.visibleGroups[k] = k === GROUP_TODAY; + } + this._renderMarkers(); + } + + // ── Top bar actions ───────────────────────────────────────────── + toggleTraffic() { + this.state.showTraffic = !this.state.showTraffic; + if (this.trafficLayer) { + this.trafficLayer.setMap(this.state.showTraffic ? this.map : null); + } + } + toggleTasks() { + this.state.showTasks = !this.state.showTasks; + this._renderMarkers(); + } + toggleTechnicians() { + this.state.showTechnicians = !this.state.showTechnicians; + this._renderMarkers(); + } + onRefresh() { + this.state.loading = true; + this._loadAndRender(); + } + openTask(taskId) { + this.actionService.switchView("form", { resId: taskId }); + } + createNewTask() { + this.actionService.doAction({ + type: "ir.actions.act_window", + res_model: "fusion.technician.task", + views: [[false, "form"]], + target: "new", + context: { default_task_type: "delivery", dialog_size: "extra-large" }, + }, { + onClose: () => { + // Refresh map data after dialog closes (task may have been created) + this.onRefresh(); + }, + }); + } +} + +window.__fusionMapOpenTask = () => {}; + +// ── Minimal ArchParser for tags (no web_map dependency) ─────── +class FusionMapArchParser { + parse(xmlDoc, models, modelName) { + const fieldNames = []; + const activeFields = {}; + if (xmlDoc && xmlDoc.querySelectorAll) { + for (const fieldEl of xmlDoc.querySelectorAll("field")) { + const name = fieldEl.getAttribute("name"); + if (name) { + fieldNames.push(name); + activeFields[name] = { attrs: {}, options: {} }; + } + } + } + return { fieldNames, activeFields }; + } +} + +// ── View registration (self-contained, no @web_map dependency) ────── +const fusionTaskMapView = { + type: "map", + display_name: _t("Map"), + icon: "oi-view-map", + multiRecord: true, + searchMenuTypes: ["filter", "groupBy", "favorite"], + Controller: FusionTaskMapController, + Model: RelationalModel, + ArchParser: FusionMapArchParser, + buttonTemplate: "fusion_claims.FusionTaskMapView.Buttons", + props(genericProps, view, config) { + const { resModel, fields } = genericProps; + let archInfo = { fieldNames: [], activeFields: {} }; + if (view && view.arch) { + archInfo = new FusionMapArchParser().parse(view.arch); + } + return { + ...genericProps, + buttonTemplate: "fusion_claims.FusionTaskMapView.Buttons", + Model: RelationalModel, + modelParams: { + config: { + resModel, + fields, + activeFields: archInfo.activeFields || {}, + isMonoRecord: false, + }, + state: { + domain: genericProps.domain || [], + context: genericProps.context || {}, + groupBy: genericProps.groupBy || [], + orderBy: genericProps.orderBy || [], + }, + }, + }; + }, +}; +registry.category("views").add("fusion_task_map", fusionTaskMapView); diff --git a/fusion_claims/fusion_claims/static/src/js/gallery_preview.js b/fusion_claims/fusion_claims/static/src/js/gallery_preview.js new file mode 100644 index 0000000..044da03 --- /dev/null +++ b/fusion_claims/fusion_claims/static/src/js/gallery_preview.js @@ -0,0 +1,120 @@ +/** @odoo-module **/ +// Fusion Claims - Gallery Preview +// Uses Odoo's native FileViewer (same as chatter) +// Copyright 2024-2025 Nexa Systems Inc. +// License OPL-1 + +import { patch } from "@web/core/utils/patch"; +import { Many2ManyBinaryField } from "@web/views/fields/many2many_binary/many2many_binary_field"; +import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook"; +import { onMounted, onWillUnmount } from "@odoo/owl"; + +/** + * Patch Many2ManyBinaryField to use Odoo's native FileViewer + * when inside our gallery section (fc-gallery-content class) + */ +patch(Many2ManyBinaryField.prototype, { + setup() { + super.setup(); + + // Use Odoo's native file viewer hook (same as chatter) + this.fileViewer = useFileViewer(); + + // Bind the click handler + this._onGalleryClick = this._onGalleryClick.bind(this); + + onMounted(() => { + // Find if we're inside a gallery section + const el = this.__owl__.bdom?.el; + if (el) { + const gallery = el.closest('.fc-gallery-content'); + if (gallery) { + // Add click listener to intercept downloads + el.addEventListener('click', this._onGalleryClick, true); + this._galleryElement = el; + } + } + }); + + onWillUnmount(() => { + if (this._galleryElement) { + this._galleryElement.removeEventListener('click', this._onGalleryClick, true); + } + }); + }, + + /** + * Handle clicks on attachments in gallery - intercept and use FileViewer + */ + _onGalleryClick(ev) { + // Check if click is anywhere inside an attachment box + const attachmentBox = ev.target.closest('.o_attachment'); + + if (!attachmentBox) { + return; // Not an attachment click + } + + // Skip if clicking on the delete button + if (ev.target.closest('.o_attachment_delete')) { + return; + } + + // Get file ID from any link or image within the attachment box + let fileId = null; + + // Try to get from link href + const link = attachmentBox.querySelector('a[href*="/web/content/"], a[href*="/web/image/"]'); + if (link) { + const href = link.getAttribute('href') || ''; + const match = href.match(/\/web\/(?:content|image)\/(\d+)/); + if (match) { + fileId = parseInt(match[1], 10); + } + } + + // Try to get from image src + if (!fileId) { + const imgEl = attachmentBox.querySelector('img[src*="/web/image/"]'); + if (imgEl) { + const src = imgEl.getAttribute('src') || ''; + const match = src.match(/\/web\/image\/(\d+)/); + if (match) { + fileId = parseInt(match[1], 10); + } + } + } + + if (!fileId) { + return; // Couldn't determine file ID + } + + // Prevent download + ev.preventDefault(); + ev.stopPropagation(); + + // Get all files and transform to FileViewer format + const files = this.files.map(file => { + const mimetype = file.mimetype || 'image/png'; + const isImage = mimetype.startsWith('image/'); + const isPdf = mimetype === 'application/pdf'; + + return { + id: file.id, + name: file.name || 'File', + mimetype: mimetype, + isImage: isImage, + isPdf: isPdf, + isViewable: isImage || isPdf, + defaultSource: isImage ? `/web/image/${file.id}` : `/web/content/${file.id}`, + downloadUrl: `/web/content/${file.id}?download=true`, + }; + }); + + // Find the clicked file and open FileViewer + const clickedFile = files.find(f => f.id === fileId); + + if (clickedFile && this.fileViewer) { + this.fileViewer.open(clickedFile, files); + } + } +}); diff --git a/fusion_claims/fusion_claims/static/src/js/google_address_autocomplete.js b/fusion_claims/fusion_claims/static/src/js/google_address_autocomplete.js new file mode 100644 index 0000000..70d4d6e --- /dev/null +++ b/fusion_claims/fusion_claims/static/src/js/google_address_autocomplete.js @@ -0,0 +1,1480 @@ +/** @odoo-module **/ +// Fusion Claims - Google Address Autocomplete for Contacts +// Copyright 2024-2025 Nexa Systems Inc. +// License OPL-1 + +import { FormController } from "@web/views/form/form_controller"; +import { useService } from "@web/core/utils/hooks"; +import { onMounted, onWillUnmount } from "@odoo/owl"; +import { patch } from "@web/core/utils/patch"; + +// Store for autocomplete instances and services +let googleMapsLoaded = false; +let googleMapsLoading = false; +let googleMapsApiKey = null; +let globalOrm = null; +const autocompleteInstances = new Map(); + +/** + * Load Google Maps API dynamically + */ +async function loadGoogleMapsApi(apiKey) { + if (googleMapsLoaded) { + return Promise.resolve(); + } + + if (googleMapsLoading) { + return new Promise((resolve) => { + const checkLoaded = setInterval(() => { + if (googleMapsLoaded) { + clearInterval(checkLoaded); + resolve(); + } + }, 100); + }); + } + + googleMapsLoading = true; + + return new Promise((resolve, reject) => { + window.initGoogleMapsAutocomplete = () => { + googleMapsLoaded = true; + googleMapsLoading = false; + resolve(); + }; + + const script = document.createElement('script'); + script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=initGoogleMapsAutocomplete`; + script.async = true; + script.defer = true; + script.onerror = () => { + googleMapsLoading = false; + reject(new Error('Failed to load Google Maps API')); + }; + document.head.appendChild(script); + }); +} + +/** + * Get API key from Odoo config + */ +async function getGoogleMapsApiKey(orm) { + if (googleMapsApiKey) { + return googleMapsApiKey; + } + + try { + const result = await orm.call( + 'ir.config_parameter', + 'get_param', + ['fusion_claims.google_maps_api_key'] + ); + googleMapsApiKey = result || null; + return googleMapsApiKey; + } catch (error) { + console.warn('Could not fetch Google Maps API key:', error); + return null; + } +} + +/** + * Initialize autocomplete on a street input field + */ +function initAutocompleteOnField(input, formModel) { + if (!input || !window.google || !window.google.maps || !window.google.maps.places) { + return null; + } + + if (autocompleteInstances.has(input)) { + return autocompleteInstances.get(input); + } + + const autocomplete = new google.maps.places.Autocomplete(input, { + componentRestrictions: { country: 'ca' }, + types: ['address'], + fields: ['address_components', 'formatted_address', 'geometry'] + }); + + autocomplete.addListener('place_changed', async () => { + const place = autocomplete.getPlace(); + if (!place.address_components) { + return; + } + + // Parse address components + let streetNumber = ''; + let streetName = ''; + let unitNumber = ''; // For apartment/unit/suite + let city = ''; + let province = ''; + let postalCode = ''; + let countryCode = ''; + + for (const component of place.address_components) { + const types = component.types; + + if (types.includes('street_number')) { + streetNumber = component.long_name; + } else if (types.includes('route')) { + streetName = component.long_name; + } else if (types.includes('subpremise')) { + // Unit, apartment, suite number + unitNumber = component.long_name; + } else if (types.includes('floor')) { + // Floor number + if (!unitNumber) unitNumber = 'Floor ' + component.long_name; + } else if (types.includes('locality')) { + city = component.long_name; + } else if (types.includes('sublocality_level_1') && !city) { + city = component.long_name; + } else if (types.includes('administrative_area_level_1')) { + province = component.short_name; + } else if (types.includes('postal_code')) { + postalCode = component.long_name; + } else if (types.includes('country')) { + countryCode = component.short_name; + } + } + + const street = streetNumber ? `${streetNumber} ${streetName}` : streetName; + + console.log('[GooglePlaces] Parsed address:', { street, unitNumber, city, province, postalCode, countryCode }); + + if (!formModel || !formModel.root) { + console.warn('[GooglePlaces] No form model available'); + return; + } + + const record = formModel.root; + + // First, lookup country and state IDs + let countryId = null; + let stateId = null; + + if (globalOrm && countryCode) { + try { + // Find country + const countries = await globalOrm.searchRead( + 'res.country', + [['code', '=', countryCode]], + ['id'], + { limit: 1 } + ); + + if (countries.length) { + countryId = countries[0].id; + console.log('[GooglePlaces] Found country ID:', countryId); + + // Find state + if (province) { + const states = await globalOrm.searchRead( + 'res.country.state', + [['code', '=', province], ['country_id', '=', countryId]], + ['id'], + { limit: 1 } + ); + + if (states.length) { + stateId = states[0].id; + console.log('[GooglePlaces] Found state ID:', stateId); + } + } + } + } catch (error) { + console.error('[GooglePlaces] Error looking up country/state:', error); + } + } + + // Build complete update payload + const updatePayload = {}; + if (street) updatePayload.street = street; + if (unitNumber) updatePayload.street2 = unitNumber; + if (city) updatePayload.city = city; + if (postalCode) updatePayload.zip = postalCode; + if (countryId !== null) updatePayload.country_id = countryId; + if (stateId !== null) updatePayload.state_id = stateId; + + console.log('[GooglePlaces] Full update payload:', updatePayload); + console.log('[GooglePlaces] Record ID:', record.resId); + console.log('[GooglePlaces] Country ID to set:', countryId); + console.log('[GooglePlaces] State ID to set:', stateId); + + // If record is saved (has ID), use ORM write directly + if (record.resId && globalOrm) { + try { + console.log('[GooglePlaces] Using ORM write for saved record'); + + // First write country and text fields + const firstWrite = { ...updatePayload }; + delete firstWrite.state_id; // Remove state from first write + + await globalOrm.write('res.partner', [record.resId], firstWrite); + console.log('[GooglePlaces] First write (country + text) successful'); + + // Then write state separately after a small delay + if (stateId !== null) { + await new Promise(resolve => setTimeout(resolve, 100)); + await globalOrm.write('res.partner', [record.resId], { state_id: stateId }); + console.log('[GooglePlaces] Second write (state) successful'); + } + + // Reload the record to show updated values + await record.load(); + console.log('[GooglePlaces] Record reloaded'); + } catch (error) { + console.error('[GooglePlaces] ORM write failed:', error); + } + } else { + // For new records - use DOM manipulation approach + console.log('[GooglePlaces] New record - using DOM approach'); + + try { + // Update text fields through record.update (these work) + const textUpdate = {}; + if (street) textUpdate.street = street; + if (unitNumber) textUpdate.street2 = unitNumber; + if (city) textUpdate.city = city; + if (postalCode) textUpdate.zip = postalCode; + + if (Object.keys(textUpdate).length > 0) { + await record.update(textUpdate); + console.log('[GooglePlaces] Text fields updated'); + } + + // For Many2one fields, we need to simulate user interaction + const formEl = input.closest('.o_form_view') || input.closest('.o_content') || document.body; + + if (countryId !== null && globalOrm) { + // Get country and state names in parallel for speed + const [countryData, stateData] = await Promise.all([ + globalOrm.read('res.country', [countryId], ['display_name']), + stateId ? globalOrm.read('res.country.state', [stateId], ['display_name']) : Promise.resolve([]) + ]); + + const countryName = countryData[0]?.display_name || 'Canada'; + const stateName = stateData[0]?.display_name || province; + + // Find and update country field via DOM + await simulateMany2OneSelection(formEl, 'country_id', countryId, countryName); + + // Wait for country onchange to complete (reduced time) + await new Promise(resolve => setTimeout(resolve, 300)); + + // Now set state + if (stateId !== null) { + await simulateMany2OneSelection(formEl, 'state_id', stateId, stateName); + } + } + + } catch (error) { + console.error('[GooglePlaces] New record update failed:', error); + } + } + }); + + autocompleteInstances.set(input, autocomplete); + return autocomplete; +} + +/** + * Simulate Many2One field selection by finding the widget and triggering its update + */ +async function simulateMany2OneSelection(formEl, fieldName, valueId, displayName) { + console.log(`[GooglePlaces] Simulating selection for ${fieldName}: ${valueId} - ${displayName}`); + + // Find the field container + const fieldSelectors = [ + `[name="${fieldName}"]`, + `.o_field_widget[name="${fieldName}"]`, + `div[name="${fieldName}"]`, + ]; + + let fieldContainer = null; + for (const selector of fieldSelectors) { + fieldContainer = formEl.querySelector(selector); + if (fieldContainer) break; + } + + if (!fieldContainer) { + console.log(`[GooglePlaces] Field container not found for ${fieldName}`); + return false; + } + + console.log(`[GooglePlaces] Found field container for ${fieldName}`); + + // Try to find the OWL component instance + // In Odoo 17+, OWL components store their instance in __owl__ + let owlComponent = null; + let el = fieldContainer; + while (el && !owlComponent) { + if (el.__owl__) { + owlComponent = el.__owl__; + break; + } + el = el.parentElement; + } + + if (owlComponent && owlComponent.component) { + console.log(`[GooglePlaces] Found OWL component for ${fieldName}`); + + // Try to call the component's update method + const component = owlComponent.component; + + if (component.props && component.props.record) { + try { + // Try updating through the props record + await component.props.record.update({ [fieldName]: valueId }); + console.log(`[GooglePlaces] Updated via props.record for ${fieldName}`); + return true; + } catch (e) { + console.log(`[GooglePlaces] props.record.update failed for ${fieldName}:`, e.message); + } + } + + if (typeof component.updateValue === 'function') { + try { + await component.updateValue(valueId); + console.log(`[GooglePlaces] Updated via updateValue for ${fieldName}`); + return true; + } catch (e) { + console.log(`[GooglePlaces] updateValue failed for ${fieldName}:`, e.message); + } + } + } + + // Fallback: Try to find and manipulate the input directly + const inputEl = fieldContainer.querySelector('input:not([type="hidden"])'); + if (inputEl) { + // Focus the input first + inputEl.focus(); + + // Clear and set value + inputEl.value = ''; + inputEl.value = displayName; + + // Trigger input event to open dropdown + inputEl.dispatchEvent(new InputEvent('input', { + bubbles: true, + cancelable: true, + data: displayName, + inputType: 'insertText' + })); + + console.log(`[GooglePlaces] Triggered input event for ${fieldName}, waiting for dropdown...`); + + // Wait for dropdown to appear and search results to load (reduced) + await new Promise(resolve => setTimeout(resolve, 250)); + + // Look for dropdown items with various selectors used by Odoo + const dropdownSelectors = [ + '.o-autocomplete--dropdown-menu .o-autocomplete--dropdown-item', + '.o_m2o_dropdown_option', + '.dropdown-menu .dropdown-item', + '.o-autocomplete .dropdown-item', + 'ul.ui-autocomplete li', + '.o_field_many2one_selection li', + ]; + + let found = false; + for (const selector of dropdownSelectors) { + const dropdownItems = document.querySelectorAll(selector); + console.log(`[GooglePlaces] Checking selector "${selector}": found ${dropdownItems.length} items`); + + for (const item of dropdownItems) { + const itemText = item.textContent.trim(); + if (itemText.includes(displayName) || displayName.includes(itemText)) { + // Click the item + item.click(); + console.log(`[GooglePlaces] Clicked dropdown item for ${fieldName}: "${itemText}"`); + found = true; + break; + } + } + if (found) break; + } + + if (!found) { + console.log(`[GooglePlaces] No matching dropdown item found for ${fieldName}`); + + // Try pressing Enter to select the first option + inputEl.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + bubbles: true + })); + console.log(`[GooglePlaces] Sent Enter key for ${fieldName}`); + + // Also try Tab to confirm selection + await new Promise(resolve => setTimeout(resolve, 50)); + inputEl.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Tab', + code: 'Tab', + keyCode: 9, + bubbles: true + })); + } + + // Blur to finalize + await new Promise(resolve => setTimeout(resolve, 50)); + inputEl.blur(); + + return found; + } + + return false; +} + +/** + * Initialize company name autocomplete (searches for businesses) + */ +function initCompanyAutocomplete(input, formModel) { + if (!input || !window.google || !window.google.maps || !window.google.maps.places) { + return null; + } + + const instanceKey = 'company_' + input.id; + if (autocompleteInstances.has(instanceKey)) { + return autocompleteInstances.get(instanceKey); + } + + const autocomplete = new google.maps.places.Autocomplete(input, { + componentRestrictions: { country: 'ca' }, + types: ['establishment'], // Search for businesses + fields: ['place_id', 'name', 'address_components', 'formatted_address', 'formatted_phone_number', 'international_phone_number', 'website', 'geometry'] + }); + + autocomplete.addListener('place_changed', async () => { + let place = autocomplete.getPlace(); + if (!place.name && !place.place_id) { + return; + } + + console.log('[GooglePlaces] Company selected:', place.name); + console.log('[GooglePlaces] Initial place data:', place); + + // If phone/website not in initial response, fetch place details + if (place.place_id && (!place.formatted_phone_number && !place.website)) { + try { + const service = new google.maps.places.PlacesService(document.createElement('div')); + const detailsPromise = new Promise((resolve, reject) => { + service.getDetails( + { + placeId: place.place_id, + fields: ['formatted_phone_number', 'international_phone_number', 'website', 'name', 'address_components'] + }, + (result, status) => { + if (status === google.maps.places.PlacesServiceStatus.OK) { + resolve(result); + } else { + reject(new Error('Place details failed: ' + status)); + } + } + ); + }); + + const details = await detailsPromise; + console.log('[GooglePlaces] Fetched place details:', details); + + // Merge details into place + if (details.formatted_phone_number) place.formatted_phone_number = details.formatted_phone_number; + if (details.international_phone_number) place.international_phone_number = details.international_phone_number; + if (details.website) place.website = details.website; + } catch (e) { + console.log('[GooglePlaces] Could not fetch place details:', e.message); + } + } + + console.log('[GooglePlaces] Final place data:', place); + + // Parse address components + let streetNumber = ''; + let streetName = ''; + let unitNumber = ''; // For apartment/unit/suite + let city = ''; + let province = ''; + let postalCode = ''; + let countryCode = ''; + + if (place.address_components) { + for (const component of place.address_components) { + const types = component.types; + + if (types.includes('street_number')) { + streetNumber = component.long_name; + } else if (types.includes('route')) { + streetName = component.long_name; + } else if (types.includes('subpremise')) { + // Unit, apartment, suite number + unitNumber = component.long_name; + } else if (types.includes('floor')) { + // Floor number - add to unit if no unit already + if (!unitNumber) unitNumber = 'Floor ' + component.long_name; + } else if (types.includes('locality')) { + city = component.long_name; + } else if (types.includes('sublocality_level_1') && !city) { + city = component.long_name; + } else if (types.includes('administrative_area_level_1')) { + province = component.short_name; + } else if (types.includes('postal_code')) { + postalCode = component.long_name; + } else if (types.includes('country')) { + countryCode = component.short_name; + } + } + } + + const street = streetNumber ? `${streetNumber} ${streetName}` : streetName; + console.log('[GooglePlaces] Parsed: street=', street, 'unit=', unitNumber, 'city=', city); + + if (!formModel || !formModel.root) { + return; + } + + const record = formModel.root; + + // Lookup country and state IDs + let countryId = null; + let stateId = null; + + if (globalOrm && countryCode) { + try { + const [countryData, stateData] = await Promise.all([ + globalOrm.searchRead('res.country', [['code', '=', countryCode]], ['id'], { limit: 1 }), + province ? globalOrm.searchRead('res.country.state', [['code', '=', province], ['country_id.code', '=', countryCode]], ['id'], { limit: 1 }) : Promise.resolve([]) + ]); + + if (countryData.length) countryId = countryData[0].id; + if (stateData.length) stateId = stateData[0].id; + } catch (error) { + console.error('[GooglePlaces] Error looking up country/state:', error); + } + } + + // Get phone number (prefer formatted, fallback to international) + const phoneNumber = place.formatted_phone_number || place.international_phone_number || ''; + + // Build update payload + const updatePayload = { + name: place.name, + }; + if (street) updatePayload.street = street; + if (unitNumber) updatePayload.street2 = unitNumber; + if (city) updatePayload.city = city; + if (postalCode) updatePayload.zip = postalCode; + if (phoneNumber) updatePayload.phone = phoneNumber; + if (place.website) updatePayload.website = place.website; + if (countryId) updatePayload.country_id = countryId; + if (stateId) updatePayload.state_id = stateId; + + console.log('[GooglePlaces] Phone:', phoneNumber, 'Website:', place.website, 'Unit:', unitNumber); + + console.log('[GooglePlaces] Company update payload:', updatePayload); + + // For saved records, use ORM write + if (record.resId && globalOrm) { + try { + const writePayload = { ...updatePayload }; + delete writePayload.state_id; // Write state separately + + await globalOrm.write('res.partner', [record.resId], writePayload); + + if (stateId) { + await new Promise(resolve => setTimeout(resolve, 100)); + await globalOrm.write('res.partner', [record.resId], { state_id: stateId }); + } + + await record.load(); + console.log('[GooglePlaces] Company record updated'); + } catch (error) { + console.error('[GooglePlaces] Company ORM write failed:', error); + } + } else { + // For new records, update through the form + try { + // Text fields + const textFields = {}; + if (place.name) textFields.name = place.name; + if (street) textFields.street = street; + if (unitNumber) textFields.street2 = unitNumber; + if (city) textFields.city = city; + if (postalCode) textFields.zip = postalCode; + if (phoneNumber) textFields.phone = phoneNumber; + if (place.website) textFields.website = place.website; + + console.log('[GooglePlaces] New record text fields:', textFields); + + await record.update(textFields); + + // Country and state via DOM simulation + const formEl = input.closest('.o_form_view') || input.closest('.o_content') || document.body; + + if (countryId && globalOrm) { + const [countryInfo, stateInfo] = await Promise.all([ + globalOrm.read('res.country', [countryId], ['display_name']), + stateId ? globalOrm.read('res.country.state', [stateId], ['display_name']) : Promise.resolve([]) + ]); + + await simulateMany2OneSelection(formEl, 'country_id', countryId, countryInfo[0]?.display_name || 'Canada'); + + if (stateId) { + await new Promise(resolve => setTimeout(resolve, 300)); + await simulateMany2OneSelection(formEl, 'state_id', stateId, stateInfo[0]?.display_name || province); + } + } + + console.log('[GooglePlaces] Company new record updated'); + } catch (error) { + console.error('[GooglePlaces] Company update failed:', error); + } + } + }); + + autocompleteInstances.set(instanceKey, autocomplete); + return autocomplete; +} + +/** + * Setup autocomplete for partner form + */ +async function setupPartnerAutocomplete(el, model, orm) { + globalOrm = orm; + + const apiKey = await getGoogleMapsApiKey(orm); + if (!apiKey) { + console.log('[GooglePlaces] API key not configured'); + return; + } + + try { + await loadGoogleMapsApi(apiKey); + } catch (error) { + console.warn('[GooglePlaces] Failed to load API:', error); + return; + } + + // Find street input field for address autocomplete + const streetInputSelectors = [ + 'input[name="street"]', + '.o_field_widget[name="street"] input', + '[name="street"] input', + 'div[name="street"] input', + '.o_address_street input' + ]; + + let streetInput = null; + for (const selector of streetInputSelectors) { + streetInput = el.querySelector(selector); + if (streetInput) break; + } + + if (streetInput && !autocompleteInstances.has(streetInput)) { + initAutocompleteOnField(streetInput, model); + + // Add visual indicator + streetInput.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%234CAF50\'%3E%3Cpath d=\'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z\'/%3E%3C/svg%3E")'; + streetInput.style.backgroundRepeat = 'no-repeat'; + streetInput.style.backgroundPosition = 'right 8px center'; + streetInput.style.backgroundSize = '20px'; + streetInput.style.paddingRight = '35px'; + + console.log('[GooglePlaces] Address autocomplete initialized on street field'); + } + + // Find company name input field for company autocomplete + const nameInputSelectors = [ + 'input[name="name"]', + '.o_field_widget[name="name"] input', + '[name="name"] input', + 'input.o_input[placeholder*="Lumber"]', // Odoo's placeholder for company name + '.o_field_char[name="name"] input', + ]; + + let nameInput = null; + for (const selector of nameInputSelectors) { + nameInput = el.querySelector(selector); + if (nameInput) { + console.log('[GooglePlaces] Found name input with selector:', selector); + break; + } + } + + // Check if company type is selected - try multiple ways + let isCompany = false; + + // Method 1: Check radio buttons + const companyRadio = el.querySelector('input[name="company_type"][value="company"]'); + if (companyRadio && companyRadio.checked) { + isCompany = true; + console.log('[GooglePlaces] Company detected via radio button'); + } + + // Method 2: Check for company-specific elements (building icon visible) + if (!isCompany) { + const companyIcon = el.querySelector('.fa-building, .o_field_partner_type .fa-building-o'); + if (companyIcon) { + isCompany = true; + console.log('[GooglePlaces] Company detected via building icon'); + } + } + + // Method 3: Check data attribute or class + if (!isCompany) { + const companyTypeField = el.querySelector('[name="company_type"]'); + if (companyTypeField) { + const selectedOption = companyTypeField.querySelector('.active, .selected, [aria-checked="true"]'); + if (selectedOption && selectedOption.textContent.toLowerCase().includes('company')) { + isCompany = true; + console.log('[GooglePlaces] Company detected via active option'); + } + } + } + + // Method 4: Check the model data + if (!isCompany && model && model.root && model.root.data) { + const companyType = model.root.data.company_type; + if (companyType === 'company') { + isCompany = true; + console.log('[GooglePlaces] Company detected via model data'); + } + } + + console.log('[GooglePlaces] isCompany:', isCompany, 'nameInput:', !!nameInput); + + if (nameInput) { + const instanceKey = 'company_' + (nameInput.id || 'default'); + + if (isCompany && !autocompleteInstances.has(instanceKey)) { + initCompanyAutocomplete(nameInput, model); + + // Add visual indicator (building icon) + nameInput.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%232196F3\'%3E%3Cpath d=\'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z\'/%3E%3C/svg%3E")'; + nameInput.style.backgroundRepeat = 'no-repeat'; + nameInput.style.backgroundPosition = 'right 8px center'; + nameInput.style.backgroundSize = '20px'; + nameInput.style.paddingRight = '35px'; + + console.log('[GooglePlaces] Company autocomplete initialized on name field'); + } else if (!isCompany && autocompleteInstances.has(instanceKey)) { + // Remove company autocomplete when switched to Person + autocompleteInstances.delete(instanceKey); + nameInput.style.backgroundImage = ''; + nameInput.style.paddingRight = ''; + console.log('[GooglePlaces] Company autocomplete removed (switched to Person)'); + } + } + + // Listen for company_type changes - try multiple selectors + const companyTypeSelectors = [ + 'input[name="company_type"]', + '[name="company_type"] input', + '.o_field_radio[name="company_type"] input', + '.o_field_widget[name="company_type"] input', + ]; + + for (const selector of companyTypeSelectors) { + const inputs = el.querySelectorAll(selector); + inputs.forEach(input => { + if (!input.dataset.googlePlacesListener) { + input.dataset.googlePlacesListener = 'true'; + input.addEventListener('change', () => { + console.log('[GooglePlaces] company_type changed, re-initializing...'); + setTimeout(() => { + setupPartnerAutocomplete(el, model, orm); + }, 200); + }); + console.log('[GooglePlaces] Added listener to company_type input'); + } + }); + } + + // Also listen for clicks on the company type labels/buttons + const companyTypeContainer = el.querySelector('[name="company_type"]'); + if (companyTypeContainer && !companyTypeContainer.dataset.googlePlacesListener) { + companyTypeContainer.dataset.googlePlacesListener = 'true'; + companyTypeContainer.addEventListener('click', () => { + console.log('[GooglePlaces] company_type container clicked, re-initializing...'); + setTimeout(() => { + setupPartnerAutocomplete(el, model, orm); + }, 300); + }); + } +} + +/** + * Cleanup autocomplete instances + */ +function cleanupAutocomplete(el) { + if (!el) return; + + const inputs = el.querySelectorAll('input'); + inputs.forEach(input => { + if (autocompleteInstances.has(input)) { + autocompleteInstances.delete(input); + } + }); +} + +/** + * Setup autocomplete for technician task form (address_street field) + */ +async function setupTaskAutocomplete(el, model, orm) { + globalOrm = orm; + + const apiKey = await getGoogleMapsApiKey(orm); + if (!apiKey) { + console.log('[GooglePlaces Task] API key not configured'); + return; + } + + try { + await loadGoogleMapsApi(apiKey); + } catch (error) { + console.warn('[GooglePlaces Task] Failed to load API:', error); + return; + } + + // Find address_street input + const streetSelectors = [ + 'div[name="address_street"] input', + '.o_field_widget[name="address_street"] input', + '[name="address_street"] input', + ]; + + let streetInput = null; + for (const selector of streetSelectors) { + streetInput = el.querySelector(selector); + if (streetInput) break; + } + + if (!streetInput || autocompleteInstances.has(streetInput)) { + return; + } + + _attachTaskAutocomplete(streetInput, el, model); +} + +/** + * Attach Google Places autocomplete to a task address_street input. + * Separated so it can be re-called after OWL re-renders the input. + */ +function _attachTaskAutocomplete(streetInput, el, model) { + if (!streetInput || !window.google?.maps?.places) return; + if (autocompleteInstances.has(streetInput)) return; + + console.log('[GooglePlaces Task] Attaching autocomplete on address_street'); + + const autocomplete = new google.maps.places.Autocomplete(streetInput, { + componentRestrictions: { country: 'ca' }, + types: ['address'], + fields: ['address_components', 'formatted_address', 'geometry'], + }); + + autocomplete.addListener('place_changed', async () => { + const place = autocomplete.getPlace(); + if (!place.address_components) return; + + let streetNumber = '', streetName = '', unitNumber = ''; + let city = '', province = '', postalCode = '', countryCode = ''; + let lat = 0, lng = 0; + + for (const c of place.address_components) { + const t = c.types; + if (t.includes('street_number')) streetNumber = c.long_name; + else if (t.includes('route')) streetName = c.long_name; + else if (t.includes('subpremise')) unitNumber = c.long_name; + else if (t.includes('floor') && !unitNumber) unitNumber = 'Floor ' + c.long_name; + else if (t.includes('locality')) city = c.long_name; + else if (t.includes('sublocality_level_1') && !city) city = c.long_name; + else if (t.includes('administrative_area_level_1')) province = c.short_name; + else if (t.includes('postal_code')) postalCode = c.long_name; + else if (t.includes('country')) countryCode = c.short_name; + } + + if (place.geometry && place.geometry.location) { + lat = place.geometry.location.lat(); + lng = place.geometry.location.lng(); + } + + const streetOnly = streetNumber ? `${streetNumber} ${streetName}` : streetName; + // Use the full formatted address from Google for the Street field + // so the user sees the complete address in one shot. + const fullAddress = place.formatted_address || streetOnly; + console.log('[GooglePlaces Task] Parsed:', { fullAddress, streetOnly, unitNumber, city, province, postalCode, lat, lng }); + + if (!model || !model.root) return; + const record = model.root; + + // Look up state ID + let stateId = false; + if (province && globalOrm) { + try { + const states = await globalOrm.searchRead( + 'res.country.state', + [['code', '=', province], ['country_id.code', '=', countryCode || 'CA']], + ['id'], + { limit: 1 } + ); + if (states.length) stateId = states[0].id; + } catch (e) { + console.warn('[GooglePlaces Task] State lookup failed:', e); + } + } + + // Update through the form model + // address_street gets the FULL formatted address so the user can see it. + // Hidden fields still get parsed components for data/geocoding/travel. + try { + const update = {}; + update.address_street = fullAddress; + if (unitNumber) update.address_street2 = unitNumber; + if (city) update.address_city = city; + if (postalCode) update.address_zip = postalCode; + if (lat) update.address_lat = lat; + if (lng) update.address_lng = lng; + if (stateId) update.address_state_id = stateId; + + await record.update(update); + console.log('[GooglePlaces Task] Address fields updated via model'); + } catch (err) { + console.error('[GooglePlaces Task] Update failed:', err); + } + + // After record.update(), OWL may re-render and replace the input element. + // Re-attach autocomplete on the (potentially new) input after a short delay. + setTimeout(() => { + _reattachTaskAutocomplete(el, model); + }, 400); + }); + + autocompleteInstances.set(streetInput, autocomplete); + + // Visual indicator + streetInput.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%234CAF50\'%3E%3Cpath d=\'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z\'/%3E%3C/svg%3E")'; + streetInput.style.backgroundRepeat = 'no-repeat'; + streetInput.style.backgroundPosition = 'right 8px center'; + streetInput.style.backgroundSize = '20px'; + streetInput.style.paddingRight = '35px'; + + console.log('[GooglePlaces Task] Autocomplete ready on address_street'); +} + +/** + * Re-attach autocomplete after OWL re-renders the task form input. + * Finds the current address_street input and attaches if not already done. + */ +function _reattachTaskAutocomplete(el, model) { + const streetSelectors = [ + 'div[name="address_street"] input', + '.o_field_widget[name="address_street"] input', + '[name="address_street"] input', + ]; + + let streetInput = null; + for (const selector of streetSelectors) { + streetInput = el.querySelector(selector); + if (streetInput) break; + } + + if (streetInput && !autocompleteInstances.has(streetInput)) { + console.log('[GooglePlaces Task] Re-attaching autocomplete after re-render'); + _attachTaskAutocomplete(streetInput, el, model); + } +} + +/** + * Attach Google Places autocomplete to a simple Char field (address only, no parsing). + * Sets the full formatted address string in the input. + */ +function initSimpleAddressAutocomplete(input) { + if (!input || !window.google || !window.google.maps || !window.google.maps.places) return; + if (autocompleteInstances.has(input)) return; + + const autocomplete = new google.maps.places.Autocomplete(input, { + componentRestrictions: { country: 'ca' }, + types: ['address'], + fields: ['formatted_address'], + }); + + autocomplete.addListener('place_changed', () => { + const place = autocomplete.getPlace(); + if (place && place.formatted_address) { + // Use native setter for OWL reactivity + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, 'value' + ).set; + nativeSetter.call(input, place.formatted_address); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + console.log('[GooglePlaces] Simple address set:', place.formatted_address); + } + }); + + autocompleteInstances.set(input, autocomplete); + + // Visual indicator + input.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%234CAF50\'%3E%3Cpath d=\'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z\'/%3E%3C/svg%3E")'; + input.style.backgroundRepeat = 'no-repeat'; + input.style.backgroundPosition = 'right 8px center'; + input.style.backgroundSize = '20px'; + input.style.paddingRight = '35px'; + + console.log('[GooglePlaces] Simple address autocomplete attached'); +} + +/** + * Setup autocomplete on simple address Char fields by name. + * Works for res.users (x_fc_start_address) and res.config.settings (fc_technician_start_address). + */ +async function setupSimpleAddressFields(el, orm) { + const apiKey = await getGoogleMapsApiKey(orm); + if (!apiKey) return; + try { await loadGoogleMapsApi(apiKey); } catch (e) { return; } + + const fieldNames = ['x_fc_start_address', 'fc_technician_start_address']; + for (const name of fieldNames) { + const selectors = [ + `div[name="${name}"] input`, + `.o_field_widget[name="${name}"] input`, + `[name="${name}"] input`, + ]; + for (const sel of selectors) { + const inp = el.querySelector(sel); + if (inp) { + initSimpleAddressAutocomplete(inp); + break; + } + } + } +} + +/** + * Patch FormController to add Google autocomplete for partner forms and dialog detection + */ +patch(FormController.prototype, { + setup() { + super.setup(); + + this.orm = useService("orm"); + + onMounted(() => { + // Store ORM globally for dialog watcher + globalOrm = this.orm; + + // Direct partner form + if (this.props.resModel === 'res.partner') { + setTimeout(() => { + if (this.rootRef && this.rootRef.el) { + setupPartnerAutocomplete(this.rootRef.el, this.model, this.orm); + } + }, 800); + + if (this.rootRef && this.rootRef.el) { + this._addressObserver = new MutationObserver((mutations) => { + const hasNewInputs = mutations.some(m => + m.addedNodes.length > 0 && + Array.from(m.addedNodes).some(n => + n.nodeType === 1 && (n.tagName === 'INPUT' || n.querySelector?.('input')) + ) + ); + if (hasNewInputs) { + setTimeout(() => { + setupPartnerAutocomplete(this.rootRef.el, this.model, this.orm); + }, 300); + } + }); + + this._addressObserver.observe(this.rootRef.el, { + childList: true, + subtree: true + }); + } + } + + // Technician task form + if (this.props.resModel === 'fusion.technician.task') { + setTimeout(() => { + if (this.rootRef && this.rootRef.el) { + setupTaskAutocomplete(this.rootRef.el, this.model, this.orm); + } + }, 800); + + if (this.rootRef && this.rootRef.el) { + this._taskAddressObserver = new MutationObserver((mutations) => { + const hasNewInputs = mutations.some(m => + m.addedNodes.length > 0 && + Array.from(m.addedNodes).some(n => + n.nodeType === 1 && (n.tagName === 'INPUT' || n.querySelector?.('input')) + ) + ); + if (hasNewInputs) { + setTimeout(() => { + setupTaskAutocomplete(this.rootRef.el, this.model, this.orm); + }, 300); + } + }); + + this._taskAddressObserver.observe(this.rootRef.el, { + childList: true, + subtree: true, + }); + + // "Calculate Travel" button -- handle via JS to prevent dialog close + const calcBtn = this.rootRef.el.querySelector('.o_fc_calculate_travel'); + if (calcBtn) { + const formModel = this.model; + const orm = this.orm; + calcBtn.addEventListener('click', async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const resId = formModel.root.resId; + if (!resId) { + // New unsaved record -- save first + const saved = await formModel.root.save(); + if (!saved) return; + } else { + // Existing record -- save pending changes + if (formModel.root.isDirty) { + const saved = await formModel.root.save(); + if (!saved) return; + } + } + const id = formModel.root.resId; + if (!id) return; + // Show loading state on button + const origText = calcBtn.textContent; + calcBtn.disabled = true; + calcBtn.textContent = ' Calculating...'; + try { + await orm.call( + 'fusion.technician.task', + 'action_calculate_travel_times', + [id], + ); + // Reload form data to show updated travel fields + await formModel.root.load(); + } catch (e) { + console.error('[CalcTravel] Error:', e); + } finally { + calcBtn.disabled = false; + calcBtn.textContent = origText || ' Calculate Travel'; + } + }); + } + } + } + + // Simple address autocomplete: res.partner, res.users, res.config.settings + if (this.props.resModel === 'res.partner' || this.props.resModel === 'res.users' || this.props.resModel === 'res.config.settings') { + setTimeout(() => { + if (this.rootRef && this.rootRef.el) { + setupSimpleAddressFields(this.rootRef.el, this.orm); + } + }, 800); + + if (this.rootRef && this.rootRef.el) { + this._simpleAddrObserver = new MutationObserver((mutations) => { + const hasNewInputs = mutations.some(m => + m.addedNodes.length > 0 && + Array.from(m.addedNodes).some(n => + n.nodeType === 1 && (n.tagName === 'INPUT' || n.querySelector?.('input')) + ) + ); + if (hasNewInputs) { + setTimeout(() => { + setupSimpleAddressFields(this.rootRef.el, this.orm); + }, 300); + } + }); + this._simpleAddrObserver.observe(this.rootRef.el, { childList: true, subtree: true }); + } + } + + // Start global dialog watcher once + _startDialogWatcher(this.orm); + }); + + onWillUnmount(() => { + if (this._addressObserver) { + this._addressObserver.disconnect(); + } + if (this._taskAddressObserver) { + this._taskAddressObserver.disconnect(); + } + if (this._simpleAddrObserver) { + this._simpleAddrObserver.disconnect(); + } + if (this.rootRef && this.rootRef.el) { + cleanupAutocomplete(this.rootRef.el); + } + }); + } +}); + +/** + * Start the global dialog watcher (runs once, watches forever) + */ +let _dialogWatcherStarted = false; +let _dialogCheckTimer = null; +const _processedDialogs = new WeakSet(); + +function _startDialogWatcher(orm) { + if (_dialogWatcherStarted) return; + _dialogWatcherStarted = true; + + console.log('[GooglePlaces] Starting global dialog watcher...'); + + // Pre-load the API key and Google Maps + getGoogleMapsApiKey(orm).then(apiKey => { + if (!apiKey) { + console.log('[GooglePlaces] No API key, dialog watcher disabled'); + return; + } + + loadGoogleMapsApi(apiKey).then(() => { + console.log('[GooglePlaces] API loaded, dialog watcher active'); + + // Watch for ANY DOM change on body + const observer = new MutationObserver(() => { + if (_dialogCheckTimer) clearTimeout(_dialogCheckTimer); + _dialogCheckTimer = setTimeout(_checkDialogsForPartnerForms, 600); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + }).catch(err => console.warn('[GooglePlaces] API load failed:', err)); + }).catch(err => console.warn('[GooglePlaces] API key fetch failed:', err)); +} + +/** + * Check all open dialogs for partner address forms + */ +function _checkDialogsForPartnerForms() { + // Get all visible modals/dialogs + const modals = document.querySelectorAll( + '.o_dialog, .modal, .o_FormViewDialog, [role="dialog"]' + ); + + for (const modal of modals) { + // Skip already processed + if (_processedDialogs.has(modal)) continue; + + // Skip hidden modals + if (modal.offsetParent === null && !modal.classList.contains('show')) continue; + + // Check if this dialog has a street field (partner form) + const streetInput = modal.querySelector( + '[name="street"] input, ' + + '.o_address_street input, ' + + 'div[name="street"] input, ' + + '.o_field_widget[name="street"] input' + ); + + if (!streetInput) continue; + + // Mark as processed + _processedDialogs.add(modal); + + console.log('[GooglePlaces] Found partner dialog with street field!'); + + // Get form element and model + const formEl = modal.querySelector('.o_form_view') || modal; + let formModel = null; + + // Walk through ALL elements to find OWL component with model + const allEls = formEl.querySelectorAll('*'); + for (const el of allEls) { + if (el.__owl__?.component?.model) { + formModel = el.__owl__.component.model; + console.log('[GooglePlaces] Found form model via child element'); + break; + } + } + + // Also walk up from formEl + if (!formModel) { + let node = formEl; + while (node) { + if (node.__owl__?.component?.model) { + formModel = node.__owl__.component.model; + console.log('[GooglePlaces] Found form model via parent'); + break; + } + node = node.parentElement; + } + } + + // If still no model, try the modal itself and its controller + if (!formModel) { + let node = modal; + while (node) { + const comp = node.__owl__?.component; + if (comp) { + // Check if this component has a model property + if (comp.model) { + formModel = comp.model; + console.log('[GooglePlaces] Found form model via modal OWL component'); + break; + } + // Check props for FormViewDialog + if (comp.props?.record) { + formModel = { root: comp.props.record }; + console.log('[GooglePlaces] Found form model via dialog props.record'); + break; + } + } + node = node.parentElement; + } + } + + if (!formModel) { + console.log('[GooglePlaces] No form model found - will use DOM-only approach for dialog'); + } + + // Initialize autocomplete on the street field, with a special dialog handler + _initDialogAutocomplete(streetInput, formEl, formModel, modal); + + // Watch for re-renders inside the dialog + let innerTimer = null; + const innerObs = new MutationObserver(() => { + if (innerTimer) clearTimeout(innerTimer); + innerTimer = setTimeout(() => { + const newStreet = modal.querySelector( + '[name="street"] input, .o_address_street input, div[name="street"] input' + ); + if (newStreet && !autocompleteInstances.has(newStreet)) { + _initDialogAutocomplete(newStreet, formEl, formModel, modal); + } + }, 400); + }); + innerObs.observe(modal, { childList: true, subtree: true }); + } +} + +/** + * Initialize autocomplete specifically for dialog forms (handles DOM-only updates) + */ +function _initDialogAutocomplete(streetInput, formEl, formModel, modal) { + if (!streetInput || !window.google?.maps?.places) return; + if (autocompleteInstances.has(streetInput)) return; + + const autocomplete = new google.maps.places.Autocomplete(streetInput, { + componentRestrictions: { country: 'ca' }, + types: ['address'], + fields: ['address_components', 'formatted_address'] + }); + + autocomplete.addListener('place_changed', async () => { + const place = autocomplete.getPlace(); + if (!place.address_components) return; + + // Parse address components + let streetNumber = '', streetName = '', unitNumber = ''; + let city = '', province = '', postalCode = '', countryCode = ''; + + for (const c of place.address_components) { + const t = c.types; + if (t.includes('street_number')) streetNumber = c.long_name; + else if (t.includes('route')) streetName = c.long_name; + else if (t.includes('subpremise')) unitNumber = c.long_name; + else if (t.includes('floor') && !unitNumber) unitNumber = 'Floor ' + c.long_name; + else if (t.includes('locality')) city = c.long_name; + else if (t.includes('sublocality_level_1') && !city) city = c.long_name; + else if (t.includes('administrative_area_level_1')) province = c.short_name; + else if (t.includes('postal_code')) postalCode = c.long_name; + else if (t.includes('country')) countryCode = c.short_name; + } + + const street = streetNumber ? `${streetNumber} ${streetName}` : streetName; + console.log('[GooglePlaces Dialog] Parsed:', { street, unitNumber, city, province, postalCode, countryCode }); + + // Try record.update first (works if formModel is available) + if (formModel?.root) { + try { + const textUpdate = {}; + if (street) textUpdate.street = street; + if (unitNumber) textUpdate.street2 = unitNumber; + if (city) textUpdate.city = city; + if (postalCode) textUpdate.zip = postalCode; + await formModel.root.update(textUpdate); + console.log('[GooglePlaces Dialog] Text fields updated via model'); + + // Handle country and state via model + if (countryCode && globalOrm) { + const countries = await globalOrm.searchRead('res.country', [['code', '=', countryCode]], ['id'], { limit: 1 }); + if (countries.length) { + await formModel.root.update({ country_id: countries[0].id }); + console.log('[GooglePlaces Dialog] Country set via model'); + + if (province) { + await new Promise(r => setTimeout(r, 300)); + const states = await globalOrm.searchRead('res.country.state', [['code', '=', province], ['country_id', '=', countries[0].id]], ['id'], { limit: 1 }); + if (states.length) { + await formModel.root.update({ state_id: states[0].id }); + console.log('[GooglePlaces Dialog] State set via model'); + } + } + } + } + return; + } catch (err) { + console.log('[GooglePlaces Dialog] Model update failed, falling back to DOM:', err.message); + } + } + + // DOM fallback - directly set input values in the dialog + console.log('[GooglePlaces Dialog] Using DOM fallback'); + + function setFieldValue(container, fieldName, value) { + if (!value) return; + const field = container.querySelector(`[name="${fieldName}"] input, div[name="${fieldName}"] input`); + if (field) { + // Set value and trigger input event for OWL reactivity + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + nativeInputValueSetter.call(field, value); + field.dispatchEvent(new Event('input', { bubbles: true })); + field.dispatchEvent(new Event('change', { bubbles: true })); + console.log(`[GooglePlaces Dialog] Set ${fieldName} = ${value}`); + } else { + console.log(`[GooglePlaces Dialog] Field ${fieldName} input not found`); + } + } + + // Set text fields via DOM + setFieldValue(modal, 'street', street); + setFieldValue(modal, 'street2', unitNumber); + setFieldValue(modal, 'city', city); + setFieldValue(modal, 'zip', postalCode); + + // Set country and state via Many2one simulation + if (countryCode && globalOrm) { + try { + const countries = await globalOrm.searchRead('res.country', [['code', '=', countryCode]], ['id', 'display_name'], { limit: 1 }); + if (countries.length) { + await simulateMany2OneSelection(modal, 'country_id', countries[0].id, countries[0].display_name); + + if (province) { + await new Promise(r => setTimeout(r, 500)); + const states = await globalOrm.searchRead('res.country.state', [['code', '=', province], ['country_id', '=', countries[0].id]], ['id', 'display_name'], { limit: 1 }); + if (states.length) { + await simulateMany2OneSelection(modal, 'state_id', states[0].id, states[0].display_name); + } + } + } + } catch (err) { + console.error('[GooglePlaces Dialog] Country/state lookup failed:', err); + } + } + }); + + autocompleteInstances.set(streetInput, autocomplete); + + // Add visual indicator + streetInput.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%234CAF50\'%3E%3Cpath d=\'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z\'/%3E%3C/svg%3E")'; + streetInput.style.backgroundRepeat = 'no-repeat'; + streetInput.style.backgroundPosition = 'right 8px center'; + streetInput.style.backgroundSize = '20px'; + streetInput.style.paddingRight = '35px'; + + console.log('[GooglePlaces Dialog] Autocomplete initialized on dialog street field'); +} diff --git a/fusion_claims/fusion_claims/static/src/js/preview_button_widget.js b/fusion_claims/fusion_claims/static/src/js/preview_button_widget.js new file mode 100644 index 0000000..686ff57 --- /dev/null +++ b/fusion_claims/fusion_claims/static/src/js/preview_button_widget.js @@ -0,0 +1,53 @@ +/** @odoo-module **/ +// Fusion Claims - Preview Button Widget +// Copyright 2026 Nexa Systems Inc. +// License OPL-1 + +import { registry } from "@web/core/registry"; +import { Component } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { DocumentPreviewDialog } from "./document_preview"; + +class PreviewButtonComponent extends Component { + static template = "fusion_claims.PreviewButtonWidget"; + static props = { "*": true }; + + setup() { + this.dialog = useService("dialog"); + this.notification = useService("notification"); + } + + onClick() { + const record = this.props.record; + if (!record || !record.data) { + this.notification.add("No document to preview.", { type: "warning" }); + return; + } + + const attField = record.data.attachment_id; + let attachmentId = null; + if (Array.isArray(attField)) { + attachmentId = attField[0]; + } else if (attField && typeof attField === "object" && attField.id) { + attachmentId = attField.id; + } else if (typeof attField === "number") { + attachmentId = attField; + } + + const fileName = record.data.file_name || "Document Preview"; + + if (!attachmentId) { + this.notification.add("No document to preview.", { type: "warning" }); + return; + } + + this.dialog.add(DocumentPreviewDialog, { + attachmentId: attachmentId, + title: fileName, + }); + } +} + +registry.category("view_widgets").add("preview_button", { + component: PreviewButtonComponent, +}); diff --git a/fusion_claims/fusion_claims/static/src/js/status_selection_filter.js b/fusion_claims/fusion_claims/static/src/js/status_selection_filter.js new file mode 100644 index 0000000..14b78ae --- /dev/null +++ b/fusion_claims/fusion_claims/static/src/js/status_selection_filter.js @@ -0,0 +1,63 @@ +/** @odoo-module **/ +/** + * Copyright 2024-2025 Nexa Systems Inc. + * License OPL-1 (Odoo Proprietary License v1.0) + * + * Custom Selection Field that filters out wizard-required statuses from dropdown. + * These statuses can only be set via dedicated action buttons that open reason wizards. + */ + +import { registry } from "@web/core/registry"; +import { SelectionField, selectionField } from "@web/views/fields/selection/selection_field"; + +// Statuses that can ONLY be set via buttons/wizards +// These are hidden from the dropdown to enforce workflow integrity +const CONTROLLED_STATUSES = [ + // Early workflow stages + 'assessment_scheduled', // Must use "Schedule Assessment" button + 'assessment_completed', // Must use "Complete Assessment" button + 'application_received', // Must use "Application Received" button + 'ready_submission', // Must use "Ready for Submission" button + // Submission and approval stages + 'submitted', // Must use "Submit Application" button + 'resubmitted', // Must use "Submit Application" button + 'approved', // Must use "Mark as Approved" button + 'approved_deduction', // Must use "Mark as Approved" button + // Billing stages + 'ready_bill', // Must use "Ready to Bill" button + 'billed', // Must use "Mark as Billed" button + 'case_closed', // Must use "Close Case" button + // Special statuses (require reason wizard) + 'on_hold', // Must use "Put On Hold" button + 'withdrawn', // Must use "Withdraw" button + 'denied', // Must use "Denied" button + 'cancelled', // Must use "Cancel" button + 'needs_correction', // Must use "Needs Correction" button +]; + +export class FilteredStatusSelectionField extends SelectionField { + /** + * Override to filter out wizard-required statuses from the options. + * The current status is always kept so the field displays correctly. + */ + get options() { + const allOptions = super.options; + const currentValue = this.props.record.data[this.props.name]; + + // Filter out wizard-required statuses, but keep current value + return allOptions.filter(option => { + const [value] = option; + // Keep the option if it's the current value OR if it's not a controlled status + return value === currentValue || !CONTROLLED_STATUSES.includes(value); + }); + } +} + +FilteredStatusSelectionField.template = "web.SelectionField"; + +export const filteredStatusSelectionField = { + ...selectionField, + component: FilteredStatusSelectionField, +}; + +registry.category("fields").add("filtered_status_selection", filteredStatusSelectionField); diff --git a/fusion_claims/fusion_claims/static/src/js/tax_totals_patch.js b/fusion_claims/fusion_claims/static/src/js/tax_totals_patch.js new file mode 100644 index 0000000..64569b4 --- /dev/null +++ b/fusion_claims/fusion_claims/static/src/js/tax_totals_patch.js @@ -0,0 +1,30 @@ +/** @odoo-module **/ + +import { patch } from "@web/core/utils/patch"; +import { TaxTotalsComponent } from "@account/components/tax_totals/tax_totals"; + +/** + * Patch TaxTotalsComponent to handle cases where subtotals is undefined + * This fixes the "Invalid loop expression: 'undefined' is not iterable" error + * that occurs when invoices have no tax configuration. + */ +patch(TaxTotalsComponent.prototype, { + formatData(props) { + // Call the original formatData method + super.formatData(props); + + // If totals exists but subtotals is undefined, set it to empty array + if (this.totals && this.totals.subtotals === undefined) { + this.totals.subtotals = []; + } + + // Also ensure each subtotal has tax_groups array + if (this.totals && this.totals.subtotals) { + for (const subtotal of this.totals.subtotals) { + if (subtotal.tax_groups === undefined) { + subtotal.tax_groups = []; + } + } + } + } +}); diff --git a/fusion_claims/fusion_claims/static/src/pdf/discretionary_benefits_form_template.pdf b/fusion_claims/fusion_claims/static/src/pdf/discretionary_benefits_form_template.pdf new file mode 100644 index 0000000..f83c3a6 Binary files /dev/null and b/fusion_claims/fusion_claims/static/src/pdf/discretionary_benefits_form_template.pdf differ diff --git a/fusion_claims/fusion_claims/static/src/pdf/sa_mobility_form_template.pdf b/fusion_claims/fusion_claims/static/src/pdf/sa_mobility_form_template.pdf new file mode 100644 index 0000000..043fbea Binary files /dev/null and b/fusion_claims/fusion_claims/static/src/pdf/sa_mobility_form_template.pdf differ diff --git a/fusion_claims/fusion_claims/static/src/pdf/sa_mobility_page2_sample.pdf b/fusion_claims/fusion_claims/static/src/pdf/sa_mobility_page2_sample.pdf new file mode 100644 index 0000000..6dc74b6 Binary files /dev/null and b/fusion_claims/fusion_claims/static/src/pdf/sa_mobility_page2_sample.pdf differ diff --git a/fusion_claims/fusion_claims/static/src/scss/fusion_claims.scss b/fusion_claims/fusion_claims/static/src/scss/fusion_claims.scss new file mode 100644 index 0000000..6368370 --- /dev/null +++ b/fusion_claims/fusion_claims/static/src/scss/fusion_claims.scss @@ -0,0 +1,872 @@ +// Fusion Central - Backend Styles +// Copyright 2024-2025 Nexa Systems Inc. +// License OPL-1 + +.o_fusion_central { + // Settings page styling + .fc-settings-section { + margin-bottom: 20px; + + h5 { + color: #0077b6; + border-bottom: 2px solid #0077b6; + padding-bottom: 5px; + margin-bottom: 15px; + } + } + + // Status indicators + .fc-status-created { + color: #28a745; + font-weight: 500; + } + + .fc-status-pending { + color: #ffc107; + font-weight: 500; + } +} + +// ADP portion columns styling +.o_list_view { + .o_field_monetary.fc-adp-portion { + color: #0077b6; + font-weight: 500; + } + + .o_field_monetary.fc-client-portion { + color: #28a745; + font-weight: 500; + } +} + +// ============================================================================= +// STATUS BUTTONS: Theme-friendly (light + dark mode) +// Uses Odoo CSS variables with safe fallbacks. +// ============================================================================= + +// Good / confirmed / within period (green tint) +.fc-btn-status-good { + background-color: rgba(40, 167, 69, 0.12) !important; + color: #1e7e34 !important; + border: 1px solid rgba(40, 167, 69, 0.35) !important; + + &:hover, &:focus { + background-color: rgba(40, 167, 69, 0.22) !important; + color: #1e7e34 !important; + border-color: rgba(40, 167, 69, 0.5) !important; + } + + .fa { color: inherit !important; } +} + +// Bad / not confirmed / overdue (red tint) +.fc-btn-status-bad { + background-color: rgba(220, 53, 69, 0.12) !important; + color: #bd2130 !important; + border: 1px solid rgba(220, 53, 69, 0.35) !important; + + &:hover, &:focus { + background-color: rgba(220, 53, 69, 0.22) !important; + color: #bd2130 !important; + border-color: rgba(220, 53, 69, 0.5) !important; + } + + .fa { color: inherit !important; } +} + +// Dark mode overrides +html.dark, .o_dark { + .fc-btn-status-good { + background-color: rgba(40, 167, 69, 0.18) !important; + color: #6fcf87 !important; + border-color: rgba(40, 167, 69, 0.4) !important; + + &:hover, &:focus { + background-color: rgba(40, 167, 69, 0.28) !important; + color: #6fcf87 !important; + } + } + + .fc-btn-status-bad { + background-color: rgba(220, 53, 69, 0.18) !important; + color: #f08a93 !important; + border-color: rgba(220, 53, 69, 0.4) !important; + + &:hover, &:focus { + background-color: rgba(220, 53, 69, 0.28) !important; + color: #f08a93 !important; + } + } +} + +// Also support Odoo's color-scheme media query for dark mode +@media (prefers-color-scheme: dark) { + .o_web_client:not(.o_light) { + .fc-btn-status-good { + background-color: rgba(40, 167, 69, 0.18) !important; + color: #6fcf87 !important; + border-color: rgba(40, 167, 69, 0.4) !important; + + &:hover, &:focus { + background-color: rgba(40, 167, 69, 0.28) !important; + color: #6fcf87 !important; + } + } + + .fc-btn-status-bad { + background-color: rgba(220, 53, 69, 0.18) !important; + color: #f08a93 !important; + border-color: rgba(220, 53, 69, 0.4) !important; + + &:hover, &:focus { + background-color: rgba(220, 53, 69, 0.28) !important; + color: #f08a93 !important; + } + } + } +} + +// ============================================================================= +// SALE ORDER LINE LIST: Column width control +// Odoo 19 ignores the XML width attribute on list fields. +// We use CSS on th[data-name] with table-layout:fixed to force widths. +// Product column has NO explicit width so it absorbs all remaining space. +// ============================================================================= + +.o_field_one2many[name="order_line"] .o_list_table { + table-layout: fixed !important; + width: 100% !important; + + // ---- Product column: gets ALL remaining space (no width set) ---- + // Truncate long product names with ellipsis + th[data-name="product_template_id"], + td[name="product_template_id"], + th[data-name="product_id"], + td[name="product_id"] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // ---- Serial Number: generous width ---- + th[data-name="x_fc_serial_number"] { width: 140px !important; } + + // ---- Quantity columns ---- + th[data-name="product_uom_qty"] { width: 55px !important; } + th[data-name="qty_delivered"] { width: 55px !important; } + th[data-name="qty_invoiced"] { width: 55px !important; } + + // ---- UoM ---- + th[data-name="product_uom_id"] { width: 50px !important; } + + // ---- Price / Discount / Tax / Subtotal ---- + th[data-name="price_unit"] { width: 80px !important; } + th[data-name="tax_ids"] { width: 70px !important; } + th[data-name="discount"] { width: 45px !important; } + th[data-name="price_subtotal"] { width: 90px !important; } + + // ---- ADP / Client Portion ---- + th[data-name="x_fc_adp_portion"] { width: 80px !important; } + th[data-name="x_fc_client_portion"] { width: 80px !important; } + + // ---- sale_margin optional columns ---- + th[data-name="purchase_price"] { width: 70px !important; } + th[data-name="margin"] { width: 65px !important; } + th[data-name="margin_percent"] { width: 55px !important; } + + // ---- Description (hidden by default, but set width in case user shows it) ---- + th[data-name="name"] { width: 120px !important; } + + // Tax tags: compact badges + td[name="tax_ids"] .badge { + font-size: 0.72em; + padding: 2px 4px; + } + + // All cells: allow text truncation + td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +// Responsive: scale text on smaller screens +@media (max-width: 1400px) { + .o_field_one2many[name="order_line"] .o_list_table { + font-size: 0.9em; + } +} + +@media (max-width: 1200px) { + .o_field_one2many[name="order_line"] .o_list_table { + font-size: 0.85em; + } +} + +// Form view styling for ADP fields +.o_form_view { + .fc-adp-totals { + background-color: #f8f9fa; + border-left: 4px solid #0077b6; + padding: 10px; + margin: 10px 0; + + .fc-total-label { + font-weight: 600; + color: #495057; + } + + .fc-total-value { + font-size: 1.1em; + font-weight: 700; + } + + .fc-adp-value { + color: #0077b6; + } + + .fc-client-value { + color: #28a745; + } + } +} + +// ADP Summary Line Details - constrain product column width +.o_fc_line_details { + .o_list_table { + table-layout: fixed !important; + width: 100% !important; + + // Product column - first column + td:first-child, + th:first-child { + max-width: 300px !important; + width: 40% !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + } + + // Other columns - auto size + td:not(:first-child), + th:not(:first-child) { + width: auto !important; + white-space: nowrap !important; + } + } +} + +// ============================================================================= +// CHATTER WIDTH CUSTOMIZATION +// 80%/20% split ONLY on desktop (>= 992px). +// On mobile/tablet, Odoo's default stacking (chatter below form) takes over. +// ONLY applies to main action forms, NOT modal dialogs/wizards. +// ============================================================================= + +@media (min-width: 992px) { + // Only apply to non-modal forms (forms in the main action area, not in dialogs) + .o_action_manager > .o_action > .o_form_view .o_form_renderer { + display: flex !important; + flex-wrap: nowrap !important; + + // Form content takes 80% of space + > .o_form_sheet_bg { + flex: 0 0 80% !important; + width: 80% !important; + min-width: 0 !important; + max-width: 80% !important; + } + + // Chatter container - 20% of screen + > .o-mail-ChatterContainer, + > .o-mail-Form-chatter, + > .o-aside { + flex: 0 0 20% !important; + width: 20% !important; + min-width: 20% !important; + max-width: 20% !important; + } + } + + // Additional backup selectors for chatter (non-modal only) + .o_action_manager .o-mail-ChatterContainer.o-aside { + flex: 0 0 20% !important; + width: 20% !important; + min-width: 20% !important; + max-width: 20% !important; + } + + // Force the form sheet content to expand within its container (non-modal only) + .o_action_manager .o_form_sheet_bg { + max-width: none !important; + } + + // Also target the inner form sheet (non-modal only) + .o_action_manager .o_form_sheet { + max-width: none !important; + width: 100% !important; + } +} + +// Make chatter content more compact (all screen sizes) +.o-mail-Thread { + .o-mail-Message { + padding: 6px 10px !important; + font-size: 0.9em; + } +} + +// Compact activity section +.o-mail-Activity { + padding: 4px 8px !important; +} + +// ============================================================================= +// Icon-only chatter topbar buttons (ALL screen sizes) +// "Send message" and "Log note" have RAW TEXT inside (no ). +// "WhatsApp" and "Activity" wrap text in . +// We use font-size:0 to hide text, then inject icons via ::before. +// ============================================================================= + +.o-mail-Chatter-topbar { + gap: 4px; + + // --- Send message (raw text, no span) -> envelope icon --- + .o-mail-Chatter-sendMessage { + font-size: 0 !important; + padding: 8px 12px !important; + min-width: auto; + line-height: 1; + + &::before { + font-family: "Font Awesome 5 Free", FontAwesome; + font-weight: 900; + font-size: 15px; + content: "\f0e0"; // fa-envelope + } + } + + // --- Log note (raw text, no span) -> edit icon --- + .o-mail-Chatter-logNote { + font-size: 0 !important; + padding: 8px 12px !important; + min-width: auto; + line-height: 1; + + &::before { + font-family: "Font Awesome 5 Free", FontAwesome; + font-weight: 900; + font-size: 15px; + content: "\f044"; // fa-edit / fa-pencil-square-o + } + } + + // --- WhatsApp (text in , target via hotkey) -> WhatsApp SVG --- + button[data-hotkey="shift+w"] { + > span { display: none !important; } + padding: 8px 12px !important; + min-width: auto; + line-height: 1; + + &::before { + content: ""; + display: inline-block; + width: 17px; + height: 17px; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z'/%3E%3C/svg%3E"); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + background-color: currentColor; + } + } + + // --- Activity (text in ) -> calendar icon --- + .o-mail-Chatter-activity { + > span { display: none !important; } + padding: 8px 12px !important; + min-width: auto; + line-height: 1; + + &::before { + font-family: "Font Awesome 5 Free", FontAwesome; + font-weight: 900; + font-size: 15px; + content: "\f073"; // fa-calendar + } + } + + // --- Message Authorizer (text in ) -> custom SVG --- + .o-mail-Chatter-messageAuthorizer { + > span { display: none !important; } + padding: 8px 12px !important; + min-width: auto; + line-height: 1; + + &::before { + content: ""; + display: inline-block; + width: 17px; + height: 17px; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50'%3E%3Cpath d='M 25 -0.03125 C 11.839844 -0.03125 10.148438 4.851563 10 5.40625 C 9.988281 5.453125 9.976563 5.484375 9.96875 5.53125 L 7.96875 21.5 C 7.929688 21.828125 8.070313 22.148438 8.3125 22.375 C 8.410156 22.464844 8.503906 22.546875 8.625 22.59375 C 8.363281 23.386719 8.015625 24.71875 7.75 27.03125 C 7.75 27.042969 7.75 27.050781 7.75 27.0625 C 7.304688 27.746094 7 28.65625 7 29.8125 C 7 32.0625 8.582031 33.878906 10.65625 34.40625 C 12.003906 37.898438 13.675781 41.625 15.90625 44.59375 C 18.230469 47.683594 21.238281 50 25 50 C 28.761719 50 31.769531 47.683594 34.09375 44.59375 C 36.324219 41.625 37.996094 37.898438 39.34375 34.40625 C 41.429688 33.886719 43 32.070313 43 29.8125 C 43 28.613281 42.699219 27.6875 42.25 27 C 41.984375 24.707031 41.636719 23.382813 41.375 22.59375 C 41.496094 22.546875 41.589844 22.464844 41.6875 22.375 C 41.929688 22.148438 42.074219 21.828125 42.03125 21.5 L 40.03125 5.53125 C 40.023438 5.484375 40.011719 5.453125 40 5.40625 C 39.851563 4.851563 38.160156 -0.03125 25 -0.03125 Z M 24 6 L 26 6 L 26 10 L 30 10 L 30 12 L 26 12 L 26 16 L 24 16 L 24 12 L 20 12 L 20 10 L 24 10 Z M 25 20.78125 C 29.371094 20.78125 34.777344 21.605469 38 22.15625 L 38 27.65625 L 39.15625 27.46875 C 39.15625 27.46875 39.628906 27.390625 40.0625 27.59375 C 40.496094 27.796875 41 28.15625 41 29.8125 C 41 31.300781 39.898438 32.449219 38.5 32.59375 L 37.90625 32.65625 L 37.6875 33.25 C 36.34375 36.785156 34.621094 40.554688 32.5 43.375 C 30.378906 46.195313 27.941406 48 25 48 C 22.058594 48 19.621094 46.195313 17.5 43.375 C 15.378906 40.554688 13.6875 36.785156 12.34375 33.25 L 12.125 32.65625 L 11.5 32.59375 C 10.097656 32.449219 9 31.300781 9 29.8125 C 9 28.234375 9.484375 27.878906 9.9375 27.65625 C 10.390625 27.433594 10.875 27.46875 10.875 27.46875 L 12 27.59375 L 12 22.15625 C 15.222656 21.605469 20.625 20.78125 25 20.78125 Z'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50'%3E%3Cpath d='M 25 -0.03125 C 11.839844 -0.03125 10.148438 4.851563 10 5.40625 C 9.988281 5.453125 9.976563 5.484375 9.96875 5.53125 L 7.96875 21.5 C 7.929688 21.828125 8.070313 22.148438 8.3125 22.375 C 8.410156 22.464844 8.503906 22.546875 8.625 22.59375 C 8.363281 23.386719 8.015625 24.71875 7.75 27.03125 C 7.75 27.042969 7.75 27.050781 7.75 27.0625 C 7.304688 27.746094 7 28.65625 7 29.8125 C 7 32.0625 8.582031 33.878906 10.65625 34.40625 C 12.003906 37.898438 13.675781 41.625 15.90625 44.59375 C 18.230469 47.683594 21.238281 50 25 50 C 28.761719 50 31.769531 47.683594 34.09375 44.59375 C 36.324219 41.625 37.996094 37.898438 39.34375 34.40625 C 41.429688 33.886719 43 32.070313 43 29.8125 C 43 28.613281 42.699219 27.6875 42.25 27 C 41.984375 24.707031 41.636719 23.382813 41.375 22.59375 C 41.496094 22.546875 41.589844 22.464844 41.6875 22.375 C 41.929688 22.148438 42.074219 21.828125 42.03125 21.5 L 40.03125 5.53125 C 40.023438 5.484375 40.011719 5.453125 40 5.40625 C 39.851563 4.851563 38.160156 -0.03125 25 -0.03125 Z M 24 6 L 26 6 L 26 10 L 30 10 L 30 12 L 26 12 L 26 16 L 24 16 L 24 12 L 20 12 L 20 10 L 24 10 Z M 25 20.78125 C 29.371094 20.78125 34.777344 21.605469 38 22.15625 L 38 27.65625 L 39.15625 27.46875 C 39.15625 27.46875 39.628906 27.390625 40.0625 27.59375 C 40.496094 27.796875 41 28.15625 41 29.8125 C 41 31.300781 39.898438 32.449219 38.5 32.59375 L 37.90625 32.65625 L 37.6875 33.25 C 36.34375 36.785156 34.621094 40.554688 32.5 43.375 C 30.378906 46.195313 27.941406 48 25 48 C 22.058594 48 19.621094 46.195313 17.5 43.375 C 15.378906 40.554688 13.6875 36.785156 12.34375 33.25 L 12.125 32.65625 L 11.5 32.59375 C 10.097656 32.449219 9 31.300781 9 29.8125 C 9 28.234375 9.484375 27.878906 9.9375 27.65625 C 10.390625 27.433594 10.875 27.46875 10.875 27.46875 L 12 27.59375 L 12 22.15625 C 15.222656 21.605469 20.625 20.78125 25 20.78125 Z'/%3E%3C/svg%3E"); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + background-color: currentColor; + } + } + + // --- Mic button (already icon-only from fusion_notes) --- + .fusion-notes-mic-btn, + .o-mail-Chatter-voiceNote { + padding: 8px 12px !important; + } +} + +// ============================================================================= +// XML VIEWER STYLES +// ============================================================================= + +.xml-viewer-content { + overflow: auto; + background: #1e1e1e; + + .xml-code { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace; + font-size: 13px; + line-height: 1.5; + color: #d4d4d4; + background: transparent; + white-space: pre; + tab-size: 2; + } + + // Syntax highlighting colors (VS Code dark theme inspired) + .xml-tag { + color: #569cd6; + font-weight: 500; + } + + .xml-attr { + color: #9cdcfe; + } + + .xml-value { + color: #ce9178; + } +} + +// ============================================================================= +// ADP DOCUMENTS TILE LAYOUT +// ============================================================================= + +.fc-document-tiles { + display: flex; + flex-wrap: wrap; + gap: 16px; + padding: 16px 0; +} + +.fc-document-tile { + width: 220px; + min-width: 220px; + background: var(--o-bg-card, var(--bs-body-bg)); + border: 1px solid var(--o-border-color, var(--bs-border-color)); + border-radius: 8px; + overflow: hidden; + transition: all 0.2s ease; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + + &:hover { + border-color: var(--o-action, var(--bs-primary)); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transform: translateY(-2px); + } + + // Preview area + .fc-tile-preview { + height: 140px; + display: flex; + align-items: center; + justify-content: center; + background: var(--o-bg-200, var(--bs-secondary-bg)); + border-bottom: 1px solid var(--o-border-color, var(--bs-border-color)); + position: relative; + + .fc-pdf-icon { + font-size: 48px; + color: var(--o-danger, var(--bs-danger)); + } + + .fc-xml-icon { + font-size: 48px; + color: var(--o-info, var(--bs-info)); + } + + .fc-empty-icon { + font-size: 48px; + opacity: 0.35; + } + + .fc-thumbnail { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + .fc-upload-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + + i { + font-size: 32px; + color: white; + margin-bottom: 8px; + } + + span { + color: white; + font-size: 12px; + font-weight: 500; + } + } + } + + &.fc-tile-empty:hover .fc-upload-overlay { + opacity: 1; + } + + // Tile info area + .fc-tile-info { + padding: 12px; + text-align: center; + + .fc-tile-label { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + margin-bottom: 6px; + line-height: 1.3; + } + + .fc-tile-filename { + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .fc-tile-empty-text { + font-size: 11px; + opacity: 0.5; + font-style: italic; + } + } + + // Actions bar + .fc-tile-actions { + display: flex; + justify-content: center; + border-top: 1px solid var(--o-border-color, var(--bs-border-color)); + background: var(--o-bg-100, var(--bs-tertiary-bg)); + padding: 8px; + + button { + flex: 1; + padding: 8px; + border: none; + background: transparent; + font-size: 14px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: var(--o-action, var(--bs-primary)); + color: white; + } + + &:not(:last-child) { + border-right: 1px solid var(--o-border-color, var(--bs-border-color)); + } + } + } + + // Has file state + &.fc-tile-filled { + border-color: var(--o-success, var(--bs-success)); + } + + // Required field indicator + &:has(.o_required_modifier) { + .fc-tile-label::after { + content: " *"; + color: var(--o-danger, var(--bs-danger)); + font-weight: bold; + } + + &:not(:has(.o_field_binary[value])) { + border-color: var(--o-warning, var(--bs-warning)); + } + } +} + +// Section headers for document groups +.fc-doc-section { + margin-bottom: 24px; + + .fc-doc-section-header { + font-size: 14px; + font-weight: 600; + border-bottom: 2px solid var(--o-action, var(--bs-primary)); + padding-bottom: 8px; + margin-bottom: 12px; + display: flex; + align-items: center; + + i { + margin-right: 8px; + color: var(--o-action, var(--bs-primary)); + } + } +} + +// Style the upload field in tiles +.fc-tile-upload-field { + width: 100%; + + .o_select_file_button { + width: 100%; + border: none !important; + border-radius: 0 !important; + background: transparent !important; + font-size: 12px !important; + padding: 8px !important; + + &:hover { + background: var(--o-action, var(--bs-primary)) !important; + color: white !important; + } + } + + .o_file_name { + display: none !important; + } + + .o_input_file { + display: none !important; + } + + &.o_field_binary { + display: flex; + justify-content: center; + } + + .o_form_binary_progress { + width: 100%; + padding: 4px; + } +} + +// Fix button styling in tiles +.fc-document-tile { + .btn-link { + text-decoration: none !important; + + &:hover { + text-decoration: none !important; + } + + .fc-pdf-icon:hover { + opacity: 0.7; + transform: scale(1.1); + transition: all 0.2s ease; + } + } +} + +// ============================================================================= +// APPROVAL SCREENSHOTS GALLERY +// ============================================================================= + +.fc-gallery-section { + background: var(--o-bg-100, var(--bs-tertiary-bg)); + border: 2px solid var(--o-border-color, var(--bs-border-color)); + border-radius: 8px; + padding: 16px; + margin-top: 8px; + + .fc-gallery-header { + padding-bottom: 12px; + + .fc-tile-label { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + } + + .badge { + font-size: 11px; + } + + .btn { + font-size: 12px; + padding: 4px 10px; + } + } + + .fc-gallery-content { + padding-top: 8px; + + // Style the many2many_binary widget as a gallery + .o_field_many2many_binary { + display: flex !important; + flex-wrap: wrap !important; + gap: 12px !important; + justify-content: flex-start !important; + align-items: flex-start !important; + padding-top: 4px !important; + + // Each file item as a thumbnail card + .o_attachments { + display: flex !important; + flex-wrap: wrap !important; + gap: 12px !important; + align-items: flex-start !important; + + .o_attachment { + width: 80px !important; + height: 80px !important; + margin: 0 !important; + border-radius: 6px !important; + overflow: hidden !important; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1) !important; + transition: all 0.2s ease !important; + cursor: pointer !important; + position: relative !important; + border: 2px solid transparent !important; + + &:hover { + transform: scale(1.05) !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important; + border-color: #0077b6 !important; + } + + // Image preview thumbnail - clicking opens in new tab + .o_image { + width: 100% !important; + height: 100% !important; + object-fit: cover !important; + } + + // File icon for non-images + .o_attachment_icon { + width: 100% !important; + height: 100% !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + background: #e9ecef !important; + + i { + font-size: 28px !important; + color: #6c757d !important; + } + } + + // Hide filename inside thumbnail + .o_attachment_name { + display: none !important; + } + + // Delete button styling + .o_attachment_delete { + position: absolute !important; + top: 2px !important; + right: 2px !important; + background: rgba(220, 53, 69, 0.9) !important; + color: white !important; + border-radius: 50% !important; + width: 18px !important; + height: 18px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + font-size: 10px !important; + opacity: 0 !important; + transition: opacity 0.2s !important; + z-index: 5 !important; + } + + &:hover .o_attachment_delete { + opacity: 1 !important; + } + } + } + + // Upload button - inline compact style + .o_attach, + button.o_attach, + .o_select_file_button { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 4px !important; + padding: 6px 12px !important; + border: 1px solid #28a745 !important; + border-radius: 4px !important; + background: white !important; + color: #28a745 !important; + font-size: 12px !important; + font-weight: 500 !important; + cursor: pointer !important; + transition: all 0.2s ease !important; + height: auto !important; + width: auto !important; + min-height: 32px !important; + margin-top: 4px !important; + + &:hover { + background: #28a745 !important; + color: white !important; + } + + i, .fa { + font-size: 12px !important; + margin: 0 !important; + } + } + } + } + + .fc-gallery-empty { + padding: 16px; + + .fa { + color: #adb5bd; + } + } +} + +// Google Places Autocomplete dropdown - ensure it appears above Odoo modals +.pac-container { + z-index: 100000 !important; +} + + diff --git a/fusion_claims/fusion_claims/static/src/xml/document_preview.xml b/fusion_claims/fusion_claims/static/src/xml/document_preview.xml new file mode 100644 index 0000000..b9fc0b2 --- /dev/null +++ b/fusion_claims/fusion_claims/static/src/xml/document_preview.xml @@ -0,0 +1,204 @@ + + + + + + + +
    +
    + +
    + + + + +
    +
    +
    + +
    + +
    +
    +
    + Loading... +
    +

    Loading document...

    +
    +
    + + +

    +
    + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_website_theme/fusion_website_theme/static/src/img/logo-adp.png b/fusion_website_theme/fusion_website_theme/static/src/img/logo-adp.png new file mode 100644 index 0000000..6227d4b Binary files /dev/null and b/fusion_website_theme/fusion_website_theme/static/src/img/logo-adp.png differ diff --git a/fusion_website_theme/fusion_website_theme/static/src/img/logo-ifhp.png b/fusion_website_theme/fusion_website_theme/static/src/img/logo-ifhp.png new file mode 100644 index 0000000..d7c9504 Binary files /dev/null and b/fusion_website_theme/fusion_website_theme/static/src/img/logo-ifhp.png differ diff --git a/fusion_website_theme/fusion_website_theme/static/src/img/logo-mod.png b/fusion_website_theme/fusion_website_theme/static/src/img/logo-mod.png new file mode 100644 index 0000000..26edda0 Binary files /dev/null and b/fusion_website_theme/fusion_website_theme/static/src/img/logo-mod.png differ diff --git a/fusion_website_theme/fusion_website_theme/static/src/img/logo-odsp.png b/fusion_website_theme/fusion_website_theme/static/src/img/logo-odsp.png new file mode 100644 index 0000000..61c06dd Binary files /dev/null and b/fusion_website_theme/fusion_website_theme/static/src/img/logo-odsp.png differ diff --git a/fusion_website_theme/fusion_website_theme/static/src/js/header.js b/fusion_website_theme/fusion_website_theme/static/src/js/header.js new file mode 100644 index 0000000..d372c1c --- /dev/null +++ b/fusion_website_theme/fusion_website_theme/static/src/js/header.js @@ -0,0 +1,73 @@ +/** + * Fusion Website Theme - Header Interactions + * Expandable search bar - expands right from search button + */ + +(function() { + 'use strict'; + + function initExpandableSearch() { + var searchTrigger = document.querySelector('.search-trigger'); + var searchExpanded = document.querySelector('.search-expanded'); + var searchClose = document.querySelector('.search-close'); + var searchInput = document.querySelector('.search-expanded input'); + + if (!searchTrigger || !searchExpanded) { + return; + } + + // Open search + searchTrigger.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + document.body.classList.add('search-open'); + searchExpanded.classList.add('active'); + + // Focus the input after animation + setTimeout(function() { + if (searchInput) searchInput.focus(); + }, 250); + }); + + // Close search on X button + if (searchClose) { + searchClose.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + closeSearch(); + }); + } + + // Close on Escape key + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && document.body.classList.contains('search-open')) { + closeSearch(); + } + }); + + // Close when clicking outside + document.addEventListener('click', function(e) { + if (document.body.classList.contains('search-open')) { + if (!searchExpanded.contains(e.target) && !searchTrigger.contains(e.target)) { + closeSearch(); + } + } + }); + + function closeSearch() { + document.body.classList.remove('search-open'); + searchExpanded.classList.remove('active'); + if (searchInput) searchInput.value = ''; + } + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initExpandableSearch); + } else { + initExpandableSearch(); + } + + // Also try after a small delay in case elements are loaded dynamically + setTimeout(initExpandableSearch, 500); +})(); diff --git a/fusion_website_theme/fusion_website_theme/static/src/scss/theme.scss b/fusion_website_theme/fusion_website_theme/static/src/scss/theme.scss new file mode 100644 index 0000000..19c850a --- /dev/null +++ b/fusion_website_theme/fusion_website_theme/static/src/scss/theme.scss @@ -0,0 +1,1448 @@ +// Fusion Website Theme - Westin Healthcare +// Modern, Minimal Design +// Copyright 2024-2026 Nexa Systems Inc. + +// ============================================================================= +// BRAND COLORS +// ============================================================================= + +$westin-primary: #0089BF; // Primary Blue +$westin-primary-dark: #006d99; // Darker blue for hover states +$westin-accent: #F5A623; // Golden/Orange accent (like Motion) +$westin-secondary: #8BC53F; // Secondary Green +$westin-secondary-dark: #6fa32d; // Darker green for hover +$westin-dark: #1a1a1a; // Near black +$westin-text: #333333; // Dark text +$westin-gray: #666666; // Secondary text +$westin-light-gray: #F5F5F5; // Background gray +$westin-border: #E5E5E5; +$westin-white: #FFFFFF; + +// ============================================================================= +// CSS CUSTOM PROPERTIES +// ============================================================================= + +:root { + --westin-primary: #{$westin-primary}; + --westin-accent: #{$westin-accent}; + --westin-dark: #{$westin-dark}; + --header-height: 70px; + --top-bar-height: 36px; +} + +// ============================================================================= +// WIDER CONTAINER - 1800px Modern Layout +// ============================================================================= + +// Override Odoo's default container to be wider +.container, +.container-lg, +.container-xl, +.container-xxl { + max-width: 1800px !important; + margin: 0 auto; + padding-left: 30px; + padding-right: 30px; +} + +// Full-width sections (hero, banner, CTA) - stretch but content stays centered +.westin-hero, +.westin-banner, +.westin-cta { + width: 100%; + + .container { + max-width: 1800px !important; + } +} + +// ============================================================================= +// HIDE DEFAULT ODOO ELEMENTS (We use custom header/footer) +// ============================================================================= + +// Hide default Odoo header navigation +#wrapwrap > header > nav.navbar, +#wrapwrap > header > .navbar, +header > nav.navbar { + display: none !important; +} + +// Hide default Odoo footer content (keep our custom footer) +#wrapwrap > footer > .o_footer, +#wrapwrap > footer > div:not(.westin-footer), +footer > .container:not(.westin-footer .container) { + display: none !important; +} + +// Ensure our custom elements are at the top +#wrapwrap { + > .westin-top-bar, + > .westin-header-modern { + order: -1; + } +} + +// Fix wrapwrap to be a flex container for proper ordering +#wrapwrap { + display: flex; + flex-direction: column; +} + +// ============================================================================= +// TYPOGRAPHY +// ============================================================================= + +body { + font-family: 'Inter', 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif; + color: $westin-text; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + color: $westin-dark; + margin-bottom: 0.5em; + letter-spacing: -0.02em; +} + +// ============================================================================= +// TOP BAR - Slim & Minimal +// ============================================================================= + +.westin-top-bar { + background-color: $westin-dark; + color: rgba(255, 255, 255, 0.9); + padding: 8px 0; + font-size: 0.8rem; + + .top-bar-content { + display: flex; + justify-content: space-between; + align-items: center; + } + + .funding-notice { + display: flex; + align-items: center; + gap: 12px; + + .badge-text { + font-weight: 500; + letter-spacing: 0.5px; + } + + a { + color: $westin-accent; + font-weight: 600; + + &:hover { + text-decoration: underline; + } + } + } + + .top-bar-right { + display: flex; + align-items: center; + gap: 12px; + } + + .top-phone { + color: rgba(255, 255, 255, 0.9); + + i { + margin-right: 4px; + } + + &:hover { + color: $westin-white; + } + } + + .separator { + color: rgba(255, 255, 255, 0.3); + } + + .canadian-badge { + font-weight: 500; + color: rgba(255, 255, 255, 0.7); + } +} + +// ============================================================================= +// MODERN HEADER +// ============================================================================= + +.westin-header-modern { + background-color: $westin-white; + border-bottom: 1px solid $westin-border; + position: sticky; + top: 0; + z-index: 1000; + + .header-content { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--header-height); + gap: 30px; + } + + // Logo - 30% larger + .header-logo { + flex-shrink: 0; + + img { + height: 59px; + width: auto; + } + } + + // Navigation + .header-nav { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + justify-content: center; + + .nav-item { + position: relative; + + > a, &.search-trigger { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + color: $westin-dark; + font-size: 0.9rem; + font-weight: 500; + border-radius: 6px; + transition: all 0.2s ease; + cursor: pointer; + text-decoration: none; + + i.fa-chevron-down { + font-size: 0.7rem; + opacity: 0.5; + transition: transform 0.2s ease; + } + + &:hover { + background-color: $westin-light-gray; + + i.fa-chevron-down { + transform: rotate(180deg); + } + } + } + + // Dropdown + &.has-dropdown { + .dropdown-menu { + position: absolute; + top: 100%; + left: 0; + min-width: 220px; + background: $westin-white; + border: 1px solid $westin-border; + border-radius: 8px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12); + padding: 8px; + opacity: 0; + visibility: hidden; + transform: translateY(10px); + transition: all 0.2s ease; + + a { + display: block; + padding: 10px 14px; + color: $westin-text; + font-size: 0.9rem; + border-radius: 6px; + transition: background 0.15s ease; + + &:hover { + background-color: $westin-light-gray; + color: $westin-primary; + } + } + } + + &:hover .dropdown-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); + } + } + } + + // Search Trigger + .search-trigger { + border: 1px solid $westin-border; + border-radius: 20px; + padding: 8px 16px !important; + + i { + color: $westin-gray; + } + + span { + color: $westin-gray; + } + + &:hover { + border-color: $westin-dark; + background: transparent !important; + + i, span { + color: $westin-dark; + } + } + } + } + + // Header Actions + .header-actions { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + + .action-link { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + color: $westin-text; + font-size: 0.85rem; + font-weight: 500; + border: 1px solid $westin-border; + border-radius: 20px; + transition: all 0.2s ease; + + &:hover { + border-color: $westin-dark; + background: $westin-light-gray; + } + } + + .btn-contact { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: $westin-accent; + color: $westin-dark; + font-size: 0.85rem; + font-weight: 600; + border-radius: 25px; + transition: all 0.2s ease; + + i { + font-size: 0.8rem; + transition: transform 0.2s ease; + } + + &:hover { + background: darken($westin-accent, 8%); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba($westin-accent, 0.4); + + i { + transform: translateX(3px); + } + } + } + } + + // Mobile Menu Toggle + .mobile-menu-toggle { + display: none; + flex-direction: column; + justify-content: center; + gap: 5px; + width: 30px; + height: 30px; + background: none; + border: none; + cursor: pointer; + padding: 0; + + span { + display: block; + width: 100%; + height: 2px; + background: $westin-dark; + transition: all 0.3s ease; + } + } +} + +// ============================================================================= +// EXPANDABLE SEARCH BAR - Left edge fixed at search button, expands right only +// ============================================================================= + +.westin-header-modern { + .header-content { + position: relative; + } + + // Navigation container - this is the positioning context for search-expanded + .header-nav { + position: relative; + + .nav-item { + transition: opacity 0.2s ease, visibility 0.2s ease; + } + + // Search trigger styling + .search-trigger { + position: relative; + z-index: 10; + } + + // Expanded search - INSIDE header-nav, positioned relative to nav + .search-expanded { + position: absolute; + top: 50%; + left: 0; // Start at left edge of nav (where search button is) + transform: translateY(-50%); + width: 100px; // Collapsed width + height: 40px; + background: $westin-white; + border: 2px solid $westin-primary; + border-radius: 22px; + display: flex; + align-items: center; + overflow: hidden; + opacity: 0; + visibility: hidden; + pointer-events: none; + // Animation: only width changes, left stays fixed + transition: width 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94), + opacity 0.15s ease, + visibility 0.15s ease; + z-index: 100; + + &.active { + // Expand to cover menu items (40% wider) + width: 800px; + opacity: 1; + visibility: visible; + pointer-events: auto; + } + + .search-form { + display: flex; + align-items: center; + width: 100%; + height: 100%; + padding: 0 6px 0 14px; + gap: 10px; + + > i { + font-size: 1rem; + color: $westin-primary; + flex-shrink: 0; + } + + input { + flex: 1; + border: none; + outline: none; + font-size: 0.95rem; + color: $westin-dark; + background: transparent; + min-width: 0; + height: 100%; + + &::placeholder { + color: #888; + } + } + + .search-close { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: $westin-light-gray; + border: none; + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; + flex-shrink: 0; + + i { + font-size: 0.75rem; + color: $westin-gray; + } + + &:hover { + background: $westin-dark; + + i { + color: $westin-white; + } + } + } + } + } + } +} + +// Hide menu items when search is open +body.search-open { + .westin-header-modern .header-nav { + // Hide all nav items (menu links) + .nav-item { + opacity: 0; + visibility: hidden; + pointer-events: none; + } + } +} + +// Responsive - adjust expanded width +@media (max-width: 1200px) { + .westin-header-modern .header-nav .search-expanded.active { + width: 600px; + } +} + +@media (max-width: 992px) { + .westin-header-modern .header-nav .search-expanded.active { + width: 470px; + } +} + +// ============================================================================= +// BUTTONS +// ============================================================================= + +.btn-westin-primary { + display: inline-flex; + align-items: center; + gap: 8px; + background-color: $westin-primary; + color: $westin-white; + padding: 12px 28px; + border-radius: 25px; + font-weight: 600; + font-size: 0.95rem; + border: none; + cursor: pointer; + transition: all 0.25s ease; + + &:hover { + background-color: $westin-primary-dark; + color: $westin-white; + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba($westin-primary, 0.35); + } +} + +.btn-westin-accent { + display: inline-flex; + align-items: center; + gap: 8px; + background-color: $westin-accent; + color: $westin-dark; + padding: 12px 28px; + border-radius: 25px; + font-weight: 600; + font-size: 0.95rem; + border: none; + cursor: pointer; + transition: all 0.25s ease; + + &:hover { + background-color: darken($westin-accent, 8%); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba($westin-accent, 0.4); + } +} + +.btn-westin-outline { + display: inline-flex; + align-items: center; + gap: 8px; + background-color: transparent; + color: $westin-dark; + padding: 12px 28px; + border-radius: 25px; + font-weight: 600; + font-size: 0.95rem; + border: 2px solid $westin-dark; + cursor: pointer; + transition: all 0.25s ease; + + &:hover { + background-color: $westin-dark; + color: $westin-white; + } +} + +// ============================================================================= +// HERO SECTION - Modern Clean Design +// ============================================================================= + +.westin-hero { + position: relative; + min-height: 560px; + background-color: #f8fafc; + + .hero-slide { + position: relative; + min-height: 560px; + background-size: cover; + background-position: right center; + display: flex; + align-items: center; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 60%; + height: 100%; + background: linear-gradient(90deg, rgba(255,255,255,1) 0%, rgba(255,255,255,0.98) 50%, rgba(255,255,255,0) 100%); + } + } + + .hero-content { + position: relative; + z-index: 1; + max-width: 580px; + padding: 50px 0; + + h1 { + font-size: 3rem; + line-height: 1.15; + margin-bottom: 18px; + color: $westin-dark; + font-weight: 800; + letter-spacing: -0.03em; + } + + .hero-subtitle { + font-size: 1.2rem; + font-weight: 400; + color: $westin-gray; + margin-bottom: 32px; + line-height: 1.7; + } + + .btn-hero { + display: inline-flex; + align-items: center; + gap: 10px; + background: linear-gradient(135deg, $westin-primary 0%, $westin-primary-dark 100%); + color: $westin-white; + padding: 16px 32px; + border-radius: 10px; + font-weight: 600; + font-size: 1rem; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 14px rgba(0, 137, 191, 0.3); + + i { + font-size: 0.85rem; + transition: transform 0.3s ease; + } + + &:hover { + color: $westin-white; + transform: translateY(-2px); + box-shadow: 0 8px 22px rgba(0, 137, 191, 0.4); + + i { + transform: translateX(4px); + } + } + } + } +} + +// Blue Banner +.westin-banner { + background: linear-gradient(135deg, $westin-primary 0%, $westin-primary-dark 100%); + padding: 22px 0; + text-align: center; + + h2 { + color: $westin-white; + font-size: 1.3rem; + font-weight: 600; + margin: 0; + letter-spacing: 0.01em; + } +} + +// ============================================================================= +// INTRO SECTION +// ============================================================================= + +.westin-intro { + padding: 45px 0; + background-color: $westin-white; + + p { + font-size: 1.05rem; + line-height: 1.85; + color: $westin-text; + margin: 0; + max-width: 1400px; + + a { + color: $westin-primary; + font-weight: 500; + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 0.2s ease; + + &:hover { + border-bottom-color: $westin-primary; + } + } + + strong { + color: $westin-dark; + font-weight: 700; + } + } +} + +// ============================================================================= +// PRODUCT CATEGORIES GRID - Matching WordPress +// ============================================================================= + +.westin-categories { + padding: 50px 0 70px; + background-color: #f8fafc; + + .categories-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 20px; + background-color: transparent; + border: none; + } + + .category-card { + background-color: $westin-white; + text-align: center; + padding: 20px 15px 22px; + border-radius: 12px; + border: 1px solid rgba(0, 0, 0, 0.06); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + transform: translateY(-4px); + box-shadow: 0 12px 28px rgba(0, 137, 191, 0.12); + border-color: rgba(0, 137, 191, 0.15); + + .category-image img { + transform: scale(1.08); + } + + h3 a { + color: $westin-primary-dark; + } + } + + &.featured { + background: linear-gradient(135deg, #f0f8ff 0%, #e6f4ff 100%); + border-color: rgba(0, 137, 191, 0.2); + } + + h3 { + font-size: 0.95rem; + margin-bottom: 16px; + color: $westin-dark; + font-weight: 600; + letter-spacing: -0.01em; + + a { + color: inherit; + transition: color 0.2s ease; + + &:hover { + color: $westin-primary; + } + } + } + + .category-image { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + margin-bottom: 18px; + padding: 0 10px; + + img { + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); + } + } + + .btn-view-all { + display: inline-block; + background-color: $westin-primary; + color: $westin-white; + padding: 10px 24px; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 600; + transition: all 0.25s ease; + + &:hover { + background-color: $westin-primary-dark; + color: $westin-white; + transform: translateY(-1px); + } + } + } +} + +// Responsive adjustments for categories +@media (max-width: 1400px) { + .westin-categories .categories-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +@media (max-width: 1000px) { + .westin-categories .categories-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +// ============================================================================= +// ABOUT SECTION +// ============================================================================= + +.westin-about { + padding: 80px 0; + background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%); + + .about-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 80px; + align-items: center; + } + + .about-image { + position: relative; + + &::before { + content: ''; + position: absolute; + top: 20px; + left: 20px; + right: -20px; + bottom: -20px; + background: linear-gradient(135deg, $westin-primary 0%, $westin-primary-dark 100%); + border-radius: 16px; + z-index: 0; + opacity: 0.1; + } + + img { + position: relative; + z-index: 1; + width: 100%; + border-radius: 16px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.12); + } + } + + .about-content { + h2 { + font-size: 2.5rem; + color: $westin-dark; + margin-bottom: 12px; + font-weight: 700; + letter-spacing: -0.02em; + } + + .title-underline { + width: 50px; + height: 4px; + background: linear-gradient(90deg, $westin-primary 0%, $westin-accent 100%); + border-radius: 2px; + margin-bottom: 28px; + } + + p { + font-size: 1.1rem; + line-height: 1.85; + color: $westin-gray; + margin-bottom: 30px; + } + + .btn-primary-solid { + display: inline-flex; + align-items: center; + gap: 8px; + background-color: $westin-primary; + color: $westin-white; + padding: 14px 32px; + border-radius: 10px; + font-weight: 600; + font-size: 0.95rem; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 14px rgba(0, 137, 191, 0.25); + + &:hover { + background-color: $westin-primary-dark; + color: $westin-white; + transform: translateY(-2px); + box-shadow: 0 8px 22px rgba(0, 137, 191, 0.35); + } + } + } +} + +// ============================================================================= +// STATS SECTION +// ============================================================================= + +.westin-stats { + padding: 70px 0; + background: linear-gradient(135deg, $westin-dark 0%, #2a2a2a 100%); + + .stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 40px; + } + + .stat-item { + text-align: center; + padding: 30px 20px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + transition: all 0.3s ease; + + &:hover { + background: rgba(255, 255, 255, 0.08); + transform: translateY(-2px); + } + + .stat-number { + font-size: 3.2rem; + font-weight: 800; + background: linear-gradient(135deg, $westin-white 0%, rgba(255, 255, 255, 0.85) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + line-height: 1; + margin-bottom: 12px; + letter-spacing: -0.02em; + } + + .stat-label { + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.7); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + } + } +} + +// ============================================================================= +// FUNDING SECTION +// ============================================================================= + +.westin-funding { + padding: 80px 0; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + text-align: center; + + h2 { + font-size: 2.4rem; + margin-bottom: 12px; + color: $westin-dark; + font-weight: 700; + letter-spacing: -0.02em; + } + + .title-underline { + width: 50px; + height: 4px; + background: linear-gradient(90deg, $westin-primary 0%, $westin-accent 100%); + border-radius: 2px; + margin-bottom: 20px; + + &.center { + margin-left: auto; + margin-right: auto; + } + } + + .funding-desc { + font-size: 1.1rem; + color: $westin-gray; + margin-bottom: 50px; + line-height: 1.75; + max-width: 700px; + margin-left: auto; + margin-right: auto; + } + + .funding-logos { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24px; + } + + .funding-card { + background-color: $westin-white; + border: 1px solid rgba(0, 0, 0, 0.06); + border-radius: 16px; + padding: 24px 20px; + text-align: center; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + + &:hover { + transform: translateY(-4px); + box-shadow: 0 12px 28px rgba(0, 137, 191, 0.12); + border-color: rgba(0, 137, 191, 0.15); + } + + .funding-label { + display: inline-block; + background: linear-gradient(135deg, $westin-primary 0%, $westin-primary-dark 100%); + color: $westin-white; + font-size: 0.7rem; + font-weight: 600; + padding: 8px 14px; + border-radius: 6px; + margin-bottom: 18px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + img { + max-width: 130px; + max-height: 85px; + object-fit: contain; + transition: transform 0.3s ease; + } + + &:hover img { + transform: scale(1.05); + } + } +} + +// ============================================================================= +// CTA SECTION +// ============================================================================= + +.westin-cta { + padding: 90px 0; + background: linear-gradient(135deg, $westin-primary 0%, $westin-primary-dark 100%); + text-align: center; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: -50%; + right: -10%; + width: 400px; + height: 400px; + background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%); + border-radius: 50%; + } + + &::after { + content: ''; + position: absolute; + bottom: -30%; + left: -5%; + width: 300px; + height: 300px; + background: radial-gradient(circle, rgba(255, 255, 255, 0.08) 0%, transparent 70%); + border-radius: 50%; + } + + .container { + position: relative; + z-index: 1; + } + + h2 { + font-size: 2.6rem; + margin-bottom: 12px; + color: $westin-white; + font-weight: 700; + letter-spacing: -0.02em; + } + + .title-underline { + width: 50px; + height: 4px; + background-color: rgba(255, 255, 255, 0.4); + border-radius: 2px; + margin-bottom: 20px; + + &.center { + margin-left: auto; + margin-right: auto; + } + } + + p { + font-size: 1.15rem; + margin-bottom: 35px; + color: rgba(255, 255, 255, 0.9); + line-height: 1.75; + max-width: 600px; + margin-left: auto; + margin-right: auto; + } + + .btn-cta { + display: inline-flex; + align-items: center; + gap: 8px; + background-color: $westin-white; + color: $westin-primary-dark; + padding: 16px 40px; + border-radius: 10px; + font-weight: 600; + font-size: 1rem; + border: none; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15); + + &:hover { + background-color: $westin-white; + color: $westin-primary-dark; + transform: translateY(-3px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); + } + } +} + +// Additional button styles are defined earlier in the file + +// ============================================================================= +// FOOTER +// ============================================================================= + +.westin-footer { + background-color: $westin-dark; + color: rgba(255, 255, 255, 0.85); + padding: 70px 0 25px; + + // All footer links - white/light color for dark background + a { + color: rgba(255, 255, 255, 0.85) !important; + text-decoration: none !important; + + &:hover { + color: $westin-white !important; + text-decoration: none !important; + } + + &:visited { + color: rgba(255, 255, 255, 0.85) !important; + } + } + + .footer-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 50px; + margin-bottom: 50px; + } + + .footer-col { + h4 { + color: $westin-white; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 25px; + padding-bottom: 12px; + border-bottom: 2px solid $westin-primary; + display: inline-block; + } + + ul { + list-style: none; + padding: 0; + margin: 0; + + li { + margin-bottom: 12px; + + a { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + transition: all 0.2s ease; + color: rgba(255, 255, 255, 0.85) !important; + + i { + color: $westin-secondary !important; // Green accent for icons + width: 14px; + font-size: 0.7rem; + } + + &:hover { + color: $westin-white !important; + transform: translateX(3px); + } + } + } + } + } + + .footer-contact { + .contact-item { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 15px; + font-size: 0.9rem; + + i { + color: $westin-primary; + margin-top: 4px; + width: 16px; + } + } + } + + .footer-hours { + .hours-list { + font-size: 0.85rem; + + .hour-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + } + } + + .footer-bottom { + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding-top: 25px; + display: flex; + justify-content: space-between; + align-items: center; + + .copyright { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.6); + } + + .social-links { + display: flex; + gap: 12px; + + a { + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 50%; + transition: all 0.25s ease; + + &:hover { + background-color: $westin-primary; + color: $westin-white !important; + transform: translateY(-3px); + } + } + } + } +} + +// Override default Odoo footer link colors for dark background +footer, +#wrapwrap > footer, +.o_footer { + a { + color: rgba(255, 255, 255, 0.85) !important; + + &:hover { + color: $westin-white !important; + } + } +} + +// ============================================================================= +// RESPONSIVE DESIGN +// ============================================================================= + +@media (max-width: 1200px) { + .westin-header-modern { + .header-nav { + gap: 4px; + + .nav-item > a, .nav-item.search-trigger { + padding: 8px 12px; + font-size: 0.85rem; + } + } + + .header-actions { + .location-link { + display: none; + } + } + } +} + +@media (max-width: 992px) { + .westin-header-modern { + .header-nav { + display: none; + } + + .mobile-menu-toggle { + display: flex; + } + } + + .westin-about .about-grid { + grid-template-columns: 1fr; + } + + .westin-stats { + grid-template-columns: repeat(2, 1fr); + } + + .westin-footer .footer-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .westin-top-bar { + .top-bar-content { + flex-direction: column; + gap: 6px; + text-align: center; + } + + .top-bar-right { + .canadian-badge { + display: none; + } + } + } + + .westin-header-modern { + .header-actions { + .btn-contact span { + display: none; + } + + .btn-contact { + padding: 10px; + border-radius: 50%; + } + } + } + + .westin-hero { + min-height: 350px; + + .hero-content { + padding: 30px 20px; + + h1 { + font-size: 2.2rem; + } + + h2 { + font-size: 1rem; + } + } + } + + .westin-categories .categories-grid { + grid-template-columns: repeat(2, 1fr); + } + + .westin-footer { + .footer-grid { + grid-template-columns: 1fr; + gap: 40px; + } + + .footer-bottom { + flex-direction: column; + gap: 20px; + text-align: center; + } + } +} + +@media (max-width: 480px) { + .westin-categories .categories-grid { + grid-template-columns: 1fr; + } + + .westin-stats { + grid-template-columns: repeat(2, 1fr); + gap: 20px; + + .stat-item .stat-number { + font-size: 2.2rem; + } + } + + .westin-funding .funding-logos { + flex-direction: column; + } +} + +// ============================================================================= +// UTILITY CLASSES +// ============================================================================= + +.text-westin-primary { + color: $westin-primary !important; +} + +.text-westin-accent { + color: $westin-accent !important; +} + +.bg-westin-primary { + background-color: $westin-primary !important; +} + +.bg-westin-accent { + background-color: $westin-accent !important; +} + +.bg-westin-light { + background-color: $westin-light-gray !important; +} diff --git a/fusion_website_theme/fusion_website_theme/views/homepage.xml b/fusion_website_theme/fusion_website_theme/views/homepage.xml new file mode 100644 index 0000000..700e47f --- /dev/null +++ b/fusion_website_theme/fusion_website_theme/views/homepage.xml @@ -0,0 +1,303 @@ + + + + + + + + + diff --git a/fusion_website_theme/fusion_website_theme/views/snippets.xml b/fusion_website_theme/fusion_website_theme/views/snippets.xml new file mode 100644 index 0000000..bebf6c8 --- /dev/null +++ b/fusion_website_theme/fusion_website_theme/views/snippets.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_website_theme/fusion_website_theme/views/templates.xml b/fusion_website_theme/fusion_website_theme/views/templates.xml new file mode 100644 index 0000000..f673541 --- /dev/null +++ b/fusion_website_theme/fusion_website_theme/views/templates.xml @@ -0,0 +1,268 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/garazd_product_label/garazd_product_label/LICENSE b/garazd_product_label/garazd_product_label/LICENSE new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/garazd_product_label/garazd_product_label/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/garazd_product_label/garazd_product_label/README.rst b/garazd_product_label/garazd_product_label/README.rst new file mode 100644 index 0000000..3314552 --- /dev/null +++ b/garazd_product_label/garazd_product_label/README.rst @@ -0,0 +1,55 @@ +===================== +Custom Product Labels +===================== + +.. |badge1| image:: https://img.shields.io/badge/maturity-Production-green.png + :alt: Production +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-purple.png + :target: https://www.gnu.org/licenses/lgpl-3.0.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/demo-Try%20me-FEA621.png + :target: https://garazd.biz/r/V3Y + :alt: Try me on a Demo Instance +.. |badge4| image:: https://img.shields.io/badge/link-Garazd%20Apps-154577.png + :target: https://garazd.biz/shop/custom-product-labels-2 + :alt: Get the app on Garazd Apps store + + +|badge1| |badge2| |badge3| |badge4| + + +Print custom product labels with barcode | Barcode Product Label + + +**Table of contents** + +.. contents:: + :local: + + +Credits +======= + +Authors +~~~~~~~ + +* Garazd Creation + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the Garazd Creation. + +.. image:: https://garazd.biz/logo.png + :alt: Garazd Creation + :target: https://garazd.biz + +Our mission is to create convenient and effective business solutions +based on the Odoo ERP system in the areas in which we have the maximum +expertise, such as: eCommerce, marketing, SEO, integration with +marketplaces and analytic systems, product label printing and designing. + +To solve these tasks, we create modules that complement each other, +extend the functionality of Odoo and improve the usability of the system. +Our solutions come with detailed documentation and additional materials +for easy use. diff --git a/garazd_product_label/garazd_product_label/__init__.py b/garazd_product_label/garazd_product_label/__init__.py new file mode 100644 index 0000000..9b42961 --- /dev/null +++ b/garazd_product_label/garazd_product_label/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/garazd_product_label/garazd_product_label/__manifest__.py b/garazd_product_label/garazd_product_label/__manifest__.py new file mode 100644 index 0000000..67597c8 --- /dev/null +++ b/garazd_product_label/garazd_product_label/__manifest__.py @@ -0,0 +1,35 @@ +# Copyright © 2018 Garazd Creation (https://garazd.biz) +# @author: Yurii Razumovskyi (support@garazd.biz) +# @author: Iryna Razumovska (support@garazd.biz) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html). + +{ + 'name': 'Custom Product Labels', + 'version': '19.0.1.0.0', + 'category': 'Extra Tools', + 'author': 'Garazd Creation', + 'website': 'https://garazd.biz/en/shop/category/odoo-product-labels-15', + 'license': 'LGPL-3', + 'summary': 'Print custom product labels with barcode | Barcode Product Label', + 'images': ['static/description/banner.png', 'static/description/icon.png'], + 'live_test_url': 'https://garazd.biz/r/V3Y', + 'depends': [ + 'product', + ], + 'data': [ + 'security/ir.model.access.csv', + 'data/product_data.xml', + 'data/print_label_type_data.xml', + 'report/product_label_reports.xml', + 'report/product_label_templates.xml', + 'wizard/print_product_label_views.xml', + 'views/res_config_settings_views.xml', + ], + 'demo': [ + 'demo/product_demo.xml', + ], + 'support': 'support@garazd.biz', + 'application': True, + 'installable': True, + 'auto_install': False, +} diff --git a/garazd_product_label/garazd_product_label/data/ir_filters_data.xml b/garazd_product_label/garazd_product_label/data/ir_filters_data.xml new file mode 100644 index 0000000..49ec6eb --- /dev/null +++ b/garazd_product_label/garazd_product_label/data/ir_filters_data.xml @@ -0,0 +1,12 @@ + + + + + Garazd Product Labels + ir.module.module + [("name", "ilike", "garazd_product_label")] + + + + + diff --git a/garazd_product_label/garazd_product_label/data/print_label_type_data.xml b/garazd_product_label/garazd_product_label/data/print_label_type_data.xml new file mode 100644 index 0000000..0c8f825 --- /dev/null +++ b/garazd_product_label/garazd_product_label/data/print_label_type_data.xml @@ -0,0 +1,7 @@ + + + + Products + product.product + + diff --git a/garazd_product_label/garazd_product_label/data/product_data.xml b/garazd_product_label/garazd_product_label/data/product_data.xml new file mode 100644 index 0000000..95901c9 --- /dev/null +++ b/garazd_product_label/garazd_product_label/data/product_data.xml @@ -0,0 +1,14 @@ + + + + + Blank Product + consu + Please do not delete this product! This product has been created by Garazd Product Label solution for technical purposes, and it's used for label printing. + + + + + + + diff --git a/garazd_product_label/garazd_product_label/demo/ir_filters_demo.xml b/garazd_product_label/garazd_product_label/demo/ir_filters_demo.xml new file mode 100644 index 0000000..6452fc6 --- /dev/null +++ b/garazd_product_label/garazd_product_label/demo/ir_filters_demo.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/garazd_product_label/garazd_product_label/demo/product_demo.xml b/garazd_product_label/garazd_product_label/demo/product_demo.xml new file mode 100644 index 0000000..4de163b --- /dev/null +++ b/garazd_product_label/garazd_product_label/demo/product_demo.xml @@ -0,0 +1,21 @@ + + + + + + 3333000022222 + + + + 0123456789017 + + + + 0700020543219 + + + + 01234090543216 + + + diff --git a/garazd_product_label/garazd_product_label/doc/changelog.rst b/garazd_product_label/garazd_product_label/doc/changelog.rst new file mode 100644 index 0000000..3ea3f8e --- /dev/null +++ b/garazd_product_label/garazd_product_label/doc/changelog.rst @@ -0,0 +1,18 @@ +.. _changelog: + +Changelog +========= + +`18.0.1.0.1` +------------ + +- Improve the lable 57 x 35 mm. + +- Add the app filter "Product Labels". + +`18.0.1.0.0` +------------ + +- Migration from 17.0. + + diff --git a/garazd_product_label/garazd_product_label/i18n/uk_UA.po b/garazd_product_label/garazd_product_label/i18n/uk_UA.po new file mode 100644 index 0000000..c51753e --- /dev/null +++ b/garazd_product_label/garazd_product_label/i18n/uk_UA.po @@ -0,0 +1,443 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * garazd_product_label +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-09-02 06:35+0000\n" +"PO-Revision-Date: 2024-09-02 06:35+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: garazd_product_label +#: model:ir.actions.report,print_report_name:garazd_product_label.action_report_product_label_50x38 +msgid "'Product Labels 50x38mm'" +msgstr "'Етикетки 50Ñ…38 мм'" + +#. module: garazd_product_label +#: model:ir.actions.report,print_report_name:garazd_product_label.action_report_product_label_A4_57x35 +msgid "'Product Labels 57x35mm'" +msgstr "'Етикетки 57Ñ…35 мм'" + +#. module: garazd_product_label +#: model:ir.actions.report,print_report_name:garazd_product_label.action_report_product_label_from_template +msgid "'Product Labels Custom Design'" +msgstr "'Етикетки товарів з влаÑний дизайном'" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "|" +msgstr "" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Get the Label Builder to create your own labels" +msgstr "ДізнайтеÑÑ, Ñк Ñтворювати етикетки з влаÑним дизайном за допомогою " +"Label Builder" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__barcode +msgid "Barcode" +msgstr "Штрих-код" + +#. module: garazd_product_label +#: model:product.template,name:garazd_product_label.product_blank_product_template +msgid "Blank Product" +msgstr "" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__border_width +msgid "Border" +msgstr "ОбрамленнÑ" + +#. module: garazd_product_label +#: model:ir.model.fields,help:garazd_product_label.field_print_product_label__border_width +msgid "Border width for labels (in pixels). Set \"0\" for no border." +msgstr "" +"Ширина рамки Ð´Ð»Ñ ÐµÑ‚Ð¸ÐºÐµÑ‚Ð¾Ðº (у пікÑелÑÑ…). Ð’Ñтановіть \"0\", Ñкщо рамка не " +"потрібна." + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_label_type__code +msgid "Code" +msgstr "Код" + +#. module: garazd_product_label +#: model:ir.model.constraint,message:garazd_product_label.constraint_print_label_type_print_label_type_code_uniq +msgid "Code of a print label type must be unique." +msgstr "Код типу етикетки має бути унікальним." + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__company_id +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__company_id +msgid "Company" +msgstr "КомпаніÑ" + +#. module: garazd_product_label +#: model:ir.model,name:garazd_product_label.model_res_config_settings +msgid "Config Settings" +msgstr "ÐалаштуваннÑ" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_label_type__create_uid +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__create_uid +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__create_uid +msgid "Created by" +msgstr "" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_label_type__create_date +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__create_date +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__create_date +msgid "Created on" +msgstr "" + +#. module: garazd_product_label +#: model:ir.actions.act_window,name:garazd_product_label.action_print_label_from_product +#: model:ir.actions.act_window,name:garazd_product_label.action_print_label_from_template +msgid "Custom Product Labels" +msgstr "Етикетки товарів" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__custom_value +msgid "Custom Value" +msgstr "КориÑтувацьке значеннÑ" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Decrease Qty" +msgstr "Зменшити кількіÑть" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_label_type__display_name +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__display_name +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__display_name +msgid "Display Name" +msgstr "" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "How to get Product Label Builder" +msgstr "Як отримати конÑтруктор етикеток \"Product Label Builder\"" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__humanreadable +msgid "Human readable barcode" +msgstr "Читабельний штрих-код" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_label_type__id +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__id +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__id +msgid "ID" +msgstr "" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Increase Qty" +msgstr "Збільшити кількіÑть" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__qty_initial +msgid "Initial Qty" +msgstr "ПервіÑна кількіÑть" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__is_template_report +msgid "Is Template Report" +msgstr "Шаблон звіту" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__report_id +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Label" +msgstr "Етикетка" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__qty +msgid "Label Qty" +msgstr "КількіÑть етикеток" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__label_type_id +msgid "Label Type" +msgstr "Тип етикетки" + +#. module: garazd_product_label +#: model:ir.model,name:garazd_product_label.model_print_label_type +msgid "Label Types" +msgstr "Типи етикеток" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__qty_per_product +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Label quantity per product" +msgstr "КількіÑть етикеток Ð´Ð»Ñ ÐºÐ¾Ð¶Ð½Ð¾Ð³Ð¾ товару" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Labels" +msgstr "Етикетки" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__label_ids +msgid "Labels for Products" +msgstr "Етикетки Ð´Ð»Ñ Ñ‚Ð¾Ð²Ð°Ñ€Ñ–Ð²" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__lang +msgid "Language" +msgstr "Мова" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_label_type__write_uid +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__write_uid +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_label_type__write_date +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__write_date +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__write_date +msgid "Last Updated on" +msgstr "" + +#. module: garazd_product_label +#: model:ir.model,name:garazd_product_label.model_print_product_label_line +msgid "Line with a Product Label Data" +msgstr "" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__message +msgid "Message" +msgstr "ПовідомленнÑ" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__mode +msgid "Mode" +msgstr "Режим" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_label_type__name +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__name +msgid "Name" +msgstr "Ðазва" + +#. module: garazd_product_label +#. odoo-python +#: code:addons/garazd_product_label/wizard/print_product_label.py:0 +#, python-format +msgid "Nothing to print, set the quantity of labels in the table." +msgstr "Друкувати немає чого, вÑтановіть кількіÑть етикеток у таблиці." + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Options" +msgstr "ÐалаштуваннÑ" + +#. module: garazd_product_label +#: model:ir.model.fields.selection,name:garazd_product_label.selection__print_product_label__output__pdf +msgid "PDF" +msgstr "" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__partner_id +msgid "Partner" +msgstr "Партнер" + +#. module: garazd_product_label +#: model_terms:product.template,description:garazd_product_label.product_blank_product_template +msgid "" +"Please do not delete this product! This product has been created by Garazd " +"Product Label solution for technical purposes, and it's used for label " +"printing." +msgstr "" +"Будь лаÑка, не видалÑйте цей товар! Цей товар Ñтворено модулем Ð´Ð»Ñ Ð´Ñ€ÑƒÐºÑƒ " +"етикеток від компанії Garazd Creation Ð´Ð»Ñ Ñ‚ÐµÑ…Ð½Ñ–Ñ‡Ð½Ð¸Ñ… цілей, він " +"викориÑтовуєтьÑÑ Ð´Ð»Ñ Ð´Ñ€ÑƒÐºÑƒ етикеток." + +#. module: garazd_product_label +#. odoo-python +#: code:addons/garazd_product_label/wizard/print_product_label.py:0 +#, python-format +msgid "Please select a label type." +msgstr "Будь лаÑка, оберіть тип етикетки." + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Preview" +msgstr "Попередній переглÑд" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Preview product labels" +msgstr "Попередній переглÑд товарних етикеток" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__selected +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Print" +msgstr "Друк" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Print Product Labels" +msgstr "Друк етикеток" + +#. module: garazd_product_label +#: model:ir.model.fields,help:garazd_product_label.field_print_product_label__humanreadable +msgid "Print digital code of barcode." +msgstr "Роздрукувати цифровий код штрих-коду." + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Print product labels" +msgstr "Друк товарних етикеток" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label__output +msgid "Print to" +msgstr "Друкувати в" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.res_config_settings_view_form +msgid "Print with the alternative wizard" +msgstr "Друк за допомогою альтернативного рішеннÑ" + +#. module: garazd_product_label +#: model:ir.model,name:garazd_product_label.model_product_template +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__product_id +msgid "Product" +msgstr "Товар" + +#. module: garazd_product_label +#: model:ir.actions.report,name:garazd_product_label.action_report_product_label_from_template +msgid "Product Label from your own template" +msgstr "Етикетки з вашим влаÑним дизайном" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.res_config_settings_view_form +msgid "Product Labels" +msgstr "Друк етикеток" + +#. module: garazd_product_label +#: model:ir.actions.report,name:garazd_product_label.action_report_product_label_50x38 +msgid "Product Labels 50x38mm" +msgstr "Етикетки 50Ñ…38 мм" + +#. module: garazd_product_label +#: model:ir.actions.report,name:garazd_product_label.action_report_product_label_A4_57x35 +msgid "Product Labels 57x35mm (A4, 21 pcs)" +msgstr "Етикетки 57x35 мм (A4, 21 шт)" + +#. module: garazd_product_label +#: model:ir.model,name:garazd_product_label.model_product_product +msgid "Product Variant" +msgstr "Варіант товару" + +#. module: garazd_product_label +#: model:ir.model.fields.selection,name:garazd_product_label.selection__print_product_label__mode__product_product +#: model:print.label.type,name:garazd_product_label.type_product +msgid "Products" +msgstr "Товари" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_res_config_settings__replace_standard_wizard +msgid "Replace Standard Wizard" +msgstr "Замінити Ñтандартний майÑтер" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Restore initial quantity" +msgstr "Відновити первіÑну кількіÑть етикеток" + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__sequence +msgid "Sequence" +msgstr "ПоÑлідовніÑть" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Set a certain quantity for each line." +msgstr "Ð’Ñтановіть певну кількіÑть Ð´Ð»Ñ ÐºÐ¾Ð¶Ð½Ð¾Ð³Ð¾ Ñ€Ñдка." + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Set quantity" +msgstr "Ð’Ñтановити кількіÑть" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Sort labels by a product" +msgstr "Ð¡Ð¾Ñ€Ñ‚ÑƒÐ²Ð°Ð½Ð½Ñ ÐµÑ‚Ð¸ÐºÐµÑ‚Ð¾Ðº за товаром" + +#. module: garazd_product_label +#: model:ir.model.fields,help:garazd_product_label.field_print_product_label__company_id +msgid "Specify a company for product labels." +msgstr "Вкажіть компанію Ð´Ð»Ñ Ñ‚Ð¾Ð²Ð°Ñ€Ð½Ð¸Ñ… етикеток." + +#. module: garazd_product_label +#: model:ir.model.fields,help:garazd_product_label.field_print_product_label__mode +msgid "Technical field to specify the mode of the label printing wizard." +msgstr "" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Template" +msgstr "Шаблон" + +#. module: garazd_product_label +#: model:ir.model.fields,help:garazd_product_label.field_print_product_label__lang +msgid "The language that will be used to translate label names." +msgstr "Мова, Ñка викориÑтовуватиметьÑÑ Ð´Ð»Ñ Ð¿ÐµÑ€ÐµÐºÐ»Ð°Ð´Ñƒ полів етикетки." + +#. module: garazd_product_label +#: model:ir.model.fields,help:garazd_product_label.field_print_product_label_line__custom_value +msgid "This field can be filled manually to use in label templates." +msgstr "Це поле можна заповнити вручну Ð´Ð»Ñ Ð²Ð¸ÐºÐ¾Ñ€Ð¸ÑÑ‚Ð°Ð½Ð½Ñ Ð² шаблонах етикеток." + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.print_product_label_view_form +msgid "Total" +msgstr "Ð’Ñього" + +#. module: garazd_product_label +#: model_terms:ir.ui.view,arch_db:garazd_product_label.res_config_settings_view_form +msgid "" +"Use the custom print wizard by clicking on the 'Print Labels' button instead" +" of standard" +msgstr "" +"ВикориÑтовувати альтернативний майÑтер друку, при натиÑканні на кнопку 'Друк " +"етикеток', заміÑть Ñтандартного." + +#. module: garazd_product_label +#: model:ir.model.fields,field_description:garazd_product_label.field_print_product_label_line__wizard_id +msgid "Wizard" +msgstr "МайÑтер" + +#. module: garazd_product_label +#: model:ir.model,name:garazd_product_label.model_print_product_label +msgid "Wizard to print Product Labels" +msgstr "МайÑтер Ð´Ð»Ñ Ð´Ñ€ÑƒÐºÑƒ етикеток товарів" + +#. module: garazd_product_label +#: model:ir.model.fields,help:garazd_product_label.field_print_product_label__label_type_id +msgid "" +"You can filter label templates by selecting their type. It makes sense if " +"you use additional extensions to print labels not for products only but for " +"other objects as well. Like as Stock Packages, Sales Orders, Manufacturing " +"Orders, etc. >>> To view available extensions go to the \"Actions\" menu and" +" click to the \"Get Label Extensions\"." +msgstr "" +"Ви можете фільтрувати шаблони етикеток, вибравши Ñ—Ñ… тип. Це має ÑенÑ, " +"Ñкщо ви викориÑтовуєте додаткові Ñ€Ð¾Ð·ÑˆÐ¸Ñ€ÐµÐ½Ð½Ñ Ð´Ð»Ñ Ð´Ñ€ÑƒÐºÑƒ етикеток не лише Ð´Ð»Ñ Ñ‚Ð¾Ð²Ð°Ñ€Ñ–Ð², " +"а й Ð´Ð»Ñ Ñ–Ð½ÑˆÐ¸Ñ… об'єктів, таких Ñк ÑкладÑькі переміщеннÑ, Ð·Ð°Ð¼Ð¾Ð²Ð»ÐµÐ½Ð½Ñ Ð½Ð° продаж, виробничі " +"Ð·Ð°Ð¼Ð¾Ð²Ð»ÐµÐ½Ð½Ñ Ñ‚Ð¾Ñ‰Ð¾. >>> Щоб переглÑнути доÑтупні розширеннÑ, перейдіть до меню \"Дії\" та " +"натиÑніть \"Отримати розширеннÑ\"." diff --git a/garazd_product_label/garazd_product_label/models/__init__.py b/garazd_product_label/garazd_product_label/models/__init__.py new file mode 100644 index 0000000..9649974 --- /dev/null +++ b/garazd_product_label/garazd_product_label/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_config_settings +from . import product_template +from . import product_product +from . import print_label_type diff --git a/garazd_product_label/garazd_product_label/models/print_label_type.py b/garazd_product_label/garazd_product_label/models/print_label_type.py new file mode 100644 index 0000000..e8cbaca --- /dev/null +++ b/garazd_product_label/garazd_product_label/models/print_label_type.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class PrintLabelTypePy(models.Model): + _name = "print.label.type" + _description = 'Label Types' + + name = fields.Char(required=True, translate=True) + code = fields.Char(required=True) + + _sql_constraints = [('print_label_type_code_uniq', 'UNIQUE (code)', 'Code of a print label type must be unique.')] diff --git a/garazd_product_label/garazd_product_label/models/product_product.py b/garazd_product_label/garazd_product_label/models/product_product.py new file mode 100644 index 0000000..bcd68ae --- /dev/null +++ b/garazd_product_label/garazd_product_label/models/product_product.py @@ -0,0 +1,13 @@ +from odoo import models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + def action_open_label_layout(self): + # flake8: noqa: E501 + if not self.env['ir.config_parameter'].sudo().get_param('garazd_product_label.replace_standard_wizard'): + return super(ProductProduct, self).action_open_label_layout() + action = self.env['ir.actions.act_window']._for_xml_id('garazd_product_label.action_print_label_from_product') + action['context'] = {'default_product_product_ids': self.ids} + return action diff --git a/garazd_product_label/garazd_product_label/models/product_template.py b/garazd_product_label/garazd_product_label/models/product_template.py new file mode 100644 index 0000000..b0ff3b3 --- /dev/null +++ b/garazd_product_label/garazd_product_label/models/product_template.py @@ -0,0 +1,13 @@ +from odoo import models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + def action_open_label_layout(self): + # flake8: noqa: E501 + if not self.env['ir.config_parameter'].sudo().get_param('garazd_product_label.replace_standard_wizard'): + return super(ProductTemplate, self).action_open_label_layout() + action = self.env['ir.actions.act_window']._for_xml_id('garazd_product_label.action_print_label_from_template') + action['context'] = {'default_product_template_ids': self.ids} + return action diff --git a/garazd_product_label/garazd_product_label/models/res_config_settings.py b/garazd_product_label/garazd_product_label/models/res_config_settings.py new file mode 100644 index 0000000..46ecdeb --- /dev/null +++ b/garazd_product_label/garazd_product_label/models/res_config_settings.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + replace_standard_wizard = fields.Boolean(config_parameter='garazd_product_label.replace_standard_wizard') diff --git a/garazd_product_label/garazd_product_label/report/product_label_reports.xml b/garazd_product_label/garazd_product_label/report/product_label_reports.xml new file mode 100644 index 0000000..d057295 --- /dev/null +++ b/garazd_product_label/garazd_product_label/report/product_label_reports.xml @@ -0,0 +1,68 @@ + + + + + Label A4 + A4 + 0 + 0 + Portrait + 10 + 10 + 5 + 5 + + 0 + + 96 + + + + + Label 50x38 mm + custom + 38 + 50 + Portrait + 1 + 0 + 0 + 0 + + 0 + + 96 + + + + + Product Labels 57x35mm (A4, 21 pcs) + print.product.label.line + qweb-pdf + + garazd_product_label.report_product_label_57x35_template + garazd_product_label.report_product_label_57x35_template + 'Product Labels 57x35mm' + + + + Product Labels 50x38mm + print.product.label.line + qweb-pdf + + garazd_product_label.report_product_label_50x38_template + garazd_product_label.report_product_label_50x38_template + 'Product Labels 50x38mm' + + + + Product Label from your own template + print.product.label.line + qweb-pdf + + garazd_product_label.report_product_label_from_template + garazd_product_label.report_product_label_from_template + 'Product Labels Custom Design' + + + diff --git a/garazd_product_label/garazd_product_label/report/product_label_templates.xml b/garazd_product_label/garazd_product_label/report/product_label_templates.xml new file mode 100644 index 0000000..fd45485 --- /dev/null +++ b/garazd_product_label/garazd_product_label/report/product_label_templates.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + diff --git a/garazd_product_label/garazd_product_label/security/ir.model.access.csv b/garazd_product_label/garazd_product_label/security/ir.model.access.csv new file mode 100644 index 0000000..7380a06 --- /dev/null +++ b/garazd_product_label/garazd_product_label/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +access_print_product_label_line_user,access_print_product_label_line_user,model_print_product_label_line,base.group_user,1,1,1,1 +access_print_product_label_user,access_print_product_label_user,model_print_product_label,base.group_user,1,1,1,0 +access_print_label_type_user_read,access_print_label_type_user_read,model_print_label_type,base.group_user,1,0,0,0 diff --git a/garazd_product_label/garazd_product_label/static/description/banner.png b/garazd_product_label/garazd_product_label/static/description/banner.png new file mode 100644 index 0000000..5c60967 Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/banner.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_account.png b/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_account.png new file mode 100644 index 0000000..9c436a8 Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_account.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_mrp.png b/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_mrp.png new file mode 100644 index 0000000..6c073b4 Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_mrp.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_packaging.png b/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_packaging.png new file mode 100644 index 0000000..368dbe6 Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_packaging.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_picking.png b/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_picking.png new file mode 100644 index 0000000..d2bd760 Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_picking.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_pro.png b/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_pro.png new file mode 100644 index 0000000..e79426e Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_pro.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_purchase.png b/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_purchase.png new file mode 100644 index 0000000..6814cbd Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/banner_garazd_product_label_purchase.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/banner_youtube.png b/garazd_product_label/garazd_product_label/static/description/banner_youtube.png new file mode 100644 index 0000000..85fbd89 Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/banner_youtube.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/icon.png b/garazd_product_label/garazd_product_label/static/description/icon.png new file mode 100644 index 0000000..def660e Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/icon.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/index.html b/garazd_product_label/garazd_product_label/static/description/index.html new file mode 100644 index 0000000..4ee251f --- /dev/null +++ b/garazd_product_label/garazd_product_label/static/description/index.html @@ -0,0 +1,484 @@ +
    +
    + +
      +
    • Community
    • +
    • Enterprise
    • +
    + +
    + Enterprise + Community +
    + +
    +
    + +
    +
    +
    + +
    +
    +

    Product label printing easily in Odoo

    +
    +
    +
    + +
    +
    +
    +
    Description
    +
    +

    There are a lot of cases when company business processes require having the ability to print product barcode labels. However, different printers and label makers use varied paper formats and label designs can be individual for each company.

    + +

    The Odoo Product Labels app family by Garazd Creation solves this business need and gives a usable tool to generate and print product labels with required sizes.

    + +

    This module allows printing custom product labels with barcode on various paper formats. It includes two label templates:

    +
      +
    • 57x35mm (21 pcs on the A4 paper format, 3 pcs x 7 rows)
    • +
    • 50x38mm (the Dymo label, 2" x 1.5")
    • +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    Benefits
    +
    +
    +
    +
    +
    +
    + +
    +
    Watch Demo
    +

    Watch the video tutorial

    +
    +
    +
    +
    +
    +
    +
    + +
    +
    Tested
    +

    Include unit tests

    +
    +
    +
    +
    +
    +
    +
    + +
    +
    Customize
    +

    Contact us for improvements and changes

    +
    +
    +
    +
    +
    +
    +
    + +
    +
    Try me
    +

    Demo & Test. Click on the "Live Preview" button

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    Replace the default print wizard
    +
    +
    + +
    + If you want to use this custom print wizard instead of standard, go to the menu "Settings" - "General Settings" and activate the "Print with the alternative wizard" option in the "Product Labels" section. +
    +
    + Odoo print product labels by alternative print wizard in 18.0 +
    +
    + After that, you can open this print wizard by clicking on "Print Labels" button in product forms and lists. +
    +
    + Odoo barcode labels printing in 18.0 +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    Product Selection
    +
    +
    + +
    + To print labels by the wizard, go to the "Products" or "Product Variants" menu and select one or several products. + Then click on the "Custom Product Labels" in the "Print" menu. +
    +
    + Odoo 18.0 select products to print +
    +
    By using our additional modules, you will also be able to select products from: + Stock Pickings, + Product Packaging, + Purchase Orders, + Manufacturing Orders. +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    Label Settings
    +
    +
    + +
    Label Settings
    +
    + In the print wizard form: +
    +
      +
    1. Select a Label Template.
    2. +
      + Templates can be in a variety of designs, sizes, and paper formats. Look at our other related modules. +
      +
    3. Specify the number of labels you want to print
    4. +
      You can enter a quantity value or use / buttons.
      +
    5. If you need to set a specific quantity for each label, enter the value and click on Set quantity.
    6. +
    7. After changing the label quantities, you can restore the initial values by clicking the button .
    8. +
    9. To sort labels by product, click on this button.
    10. +
    11. You can reorder the labels manually or deactivate some labels to avoid printing them.
    12. +
    +
    + Odoo 18.0 product label settings +
    + +
    Label Options
    +
    + You can also set some label options on the tab Options: +
    +
      +
    • Language - to specify the language to translate label fields.
    • +
    • Human readable barcode - to print a barcode digit code on the labels.
    • +
    • Border - to set the label border width.
    • +
      Set to 0 to print labels without border.
      +
    • Company - select a company if your label's data is related from the company.
    • +
    +
    + Odoo 18.0 product label options +
    + +
    Label Printing
    +
    + Finally, you can preview the labels before printing, by clicking on the PREVIEW button, or print them with the PRINT +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    Design your own label
    +
    +
    +
    + Create a variety of labels with awesome designs using the + + Product Label Builder +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    Label Samples
    +
    +
    + +
    + Labels will be generated in the PDF format: +
    +
    + Odoo 18.0 Custom Product Labels +
    +
    + Odoo 18.0 Custom Product Labels +
    + +
    +
    +
    +
    +
    +
    + +
    + +
    + +
    +
    + +
    +
    Contact Us
    +
    Support, customizations, and development
    + +
    + + Skype: + GarazdCreation +
    + +
    + +
    +
    Our expertise
    +
    +
    + Odoo Learning Partner +
    +
    + OCA Member +
    +
    + With Odoo since 2014 +
    +
    + Over 20,000 app downloads and purchases +
    +
    + Our apps in the TOP 10 on Odoo Apps +
    +
    +
    + +
    +
    Explore our apps
    + +
    Watch and subscribe
    + +
    +
    + +
    + +
    +
    + +
    Version: 18.0.1.0.1
    +
    Module design is reserved | Copyright © Garazd Creation
    + + +
    +
    Changelog
    +
    + + +
      +
    • + +
      +
      + 18.0.1.0.1 + 2024-10-22 +
      +
        +
      • Improve the lable 57 x 35 mm.
      • +
      • Add the app filter "Product Labels".
      • +
      +
      +
    • +
    • + +
      +
      + 18.0.1.0.0 + 2024-10-02 +
      +
        +
      • Migration from 17.0.
      • +
      +
      +
    • +
    + +
    + + +
    +
    + + + +
    +
    +
    +
    + + + Rate the app + - support us to do more! + + +
    +
    +
    +
    + diff --git a/garazd_product_label/garazd_product_label/static/description/label_print_wizard.png b/garazd_product_label/garazd_product_label/static/description/label_print_wizard.png new file mode 100644 index 0000000..e5723c6 Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/label_print_wizard.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/label_print_wizard_options.png b/garazd_product_label/garazd_product_label/static/description/label_print_wizard_options.png new file mode 100644 index 0000000..32f6d80 Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/label_print_wizard_options.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/odoo_product_barcode_label_print.png b/garazd_product_label/garazd_product_label/static/description/odoo_product_barcode_label_print.png new file mode 100644 index 0000000..478aea9 Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/odoo_product_barcode_label_print.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/odoo_product_label_alternative_print_wizard_setting.png b/garazd_product_label/garazd_product_label/static/description/odoo_product_label_alternative_print_wizard_setting.png new file mode 100644 index 0000000..952d647 Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/odoo_product_label_alternative_print_wizard_setting.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/print_product_label_options.png b/garazd_product_label/garazd_product_label/static/description/print_product_label_options.png new file mode 100644 index 0000000..2b53f67 Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/print_product_label_options.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/print_product_label_select_products.png b/garazd_product_label/garazd_product_label/static/description/print_product_label_select_products.png new file mode 100644 index 0000000..43d977f Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/print_product_label_select_products.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/print_product_label_settings.png b/garazd_product_label/garazd_product_label/static/description/print_product_label_settings.png new file mode 100644 index 0000000..611bf3f Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/print_product_label_settings.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/product-barcode-labels.png b/garazd_product_label/garazd_product_label/static/description/product-barcode-labels.png new file mode 100644 index 0000000..ba2782d Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/product-barcode-labels.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/product-labels-57x35mm.png b/garazd_product_label/garazd_product_label/static/description/product-labels-57x35mm.png new file mode 100644 index 0000000..ba2782d Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/product-labels-57x35mm.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/product_barcode_label.png b/garazd_product_label/garazd_product_label/static/description/product_barcode_label.png new file mode 100644 index 0000000..ba2782d Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/product_barcode_label.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/product_barcode_label_50x38mm.png b/garazd_product_label/garazd_product_label/static/description/product_barcode_label_50x38mm.png new file mode 100644 index 0000000..3ebddb7 Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/product_barcode_label_50x38mm.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/product_barcode_label_A4_57x35mm.png b/garazd_product_label/garazd_product_label/static/description/product_barcode_label_A4_57x35mm.png new file mode 100644 index 0000000..9adabf7 Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/product_barcode_label_A4_57x35mm.png differ diff --git a/garazd_product_label/garazd_product_label/static/description/select_products.png b/garazd_product_label/garazd_product_label/static/description/select_products.png new file mode 100644 index 0000000..2b66e4a Binary files /dev/null and b/garazd_product_label/garazd_product_label/static/description/select_products.png differ diff --git a/garazd_product_label/garazd_product_label/tests/__init__.py b/garazd_product_label/garazd_product_label/tests/__init__.py new file mode 100644 index 0000000..3b1fffe --- /dev/null +++ b/garazd_product_label/garazd_product_label/tests/__init__.py @@ -0,0 +1 @@ +from . import test_print_product_label diff --git a/garazd_product_label/garazd_product_label/tests/test_print_product_label.py b/garazd_product_label/garazd_product_label/tests/test_print_product_label.py new file mode 100644 index 0000000..6086086 --- /dev/null +++ b/garazd_product_label/garazd_product_label/tests/test_print_product_label.py @@ -0,0 +1,31 @@ +from odoo.tests.common import TransactionCase +from odoo.tools import test_reports +from odoo.tests import tagged + + +@tagged('post_install', '-at_install', 'garazd_product_label') +class TestPrintProductLabel(TransactionCase): + + def setUp(self): + super(TestPrintProductLabel, self).setUp() + self.product_chair = self.env.ref('product.product_product_12') + self.product_drawer = self.env.ref('product.product_product_27') + + def test_print_wizard(self): + ctx = { + 'active_model': 'product.product', + 'default_product_product_ids': [ + self.product_chair.id, + self.product_drawer.id, + ], + } + wizard = self.env['print.product.label'].with_context(**ctx).create({}) + self.assertEqual(len(wizard.label_ids), 2) + + test_reports.try_report( + self.env.cr, + self.env.uid, + 'garazd_product_label.report_product_label_57x35_template', + ids=wizard.label_ids.ids, + our_module='garazd_product_label' + ) diff --git a/garazd_product_label/garazd_product_label/views/res_config_settings_views.xml b/garazd_product_label/garazd_product_label/views/res_config_settings_views.xml new file mode 100644 index 0000000..3ad703a --- /dev/null +++ b/garazd_product_label/garazd_product_label/views/res_config_settings_views.xml @@ -0,0 +1,21 @@ + + + + + res.config.settings.view.form.inherit.garazd_product_label + res.config.settings + + + + + + + + + + + + + diff --git a/garazd_product_label/garazd_product_label/wizard/__init__.py b/garazd_product_label/garazd_product_label/wizard/__init__.py new file mode 100644 index 0000000..08b9b0d --- /dev/null +++ b/garazd_product_label/garazd_product_label/wizard/__init__.py @@ -0,0 +1,2 @@ +from . import print_product_label_line +from . import print_product_label diff --git a/garazd_product_label/garazd_product_label/wizard/print_product_label.py b/garazd_product_label/garazd_product_label/wizard/print_product_label.py new file mode 100644 index 0000000..d5a2c10 --- /dev/null +++ b/garazd_product_label/garazd_product_label/wizard/print_product_label.py @@ -0,0 +1,184 @@ +# Copyright © 2018 Garazd Creation () +# @author: Yurii Razumovskyi () +# @author: Iryna Razumovska () +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html). + +import base64 +from typing import List + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.addons.base.models.res_partner import _lang_get + + +class PrintProductLabel(models.TransientModel): + _name = "print.product.label" + _description = 'Wizard to print Product Labels' + + @api.model + def _complete_label_fields(self, label_ids: List[int]) -> List[int]: + """Set additional fields for product labels. Method to override.""" + # Increase a label sequence + labels = self.env['print.product.label.line'].browse(label_ids) + for seq, label in enumerate(labels): + label.sequence = 1000 + seq + return label_ids + + @api.model + def _get_product_label_ids(self): + res = [] + # flake8: noqa: E501 + if self._context.get('active_model') == 'product.template': + products = self.env[self._context.get('active_model')].browse( + self._context.get('default_product_template_ids') + ) + for product in products: + label = self.env['print.product.label.line'].create({ + 'product_id': product.product_variant_id.id, + }) + res.append(label.id) + elif self._context.get('active_model') == 'product.product': + products = self.env[self._context.get('active_model')].browse( + self._context.get('default_product_product_ids') + ) + for product in products: + label = self.env['print.product.label.line'].create({'product_id': product.id}) + res.append(label.id) + res = self._complete_label_fields(res) + return res + + @api.model + def default_get(self, fields_list): + default_vals = super(PrintProductLabel, self).default_get(fields_list) + if 'label_type_id' in fields_list and not default_vals.get('label_type_id'): + default_vals['label_type_id'] = self.env.ref('garazd_product_label.type_product').id + return default_vals + + name = fields.Char(default='Print Product Labels') + message = fields.Char(readonly=True) + output = fields.Selection(selection=[('pdf', 'PDF')], string='Print to', default='pdf') + mode = fields.Selection( + selection=[('product.product', 'Products')], + help='Technical field to specify the mode of the label printing wizard.', + default='product.product', + ) + label_type_id = fields.Many2one(comodel_name='print.label.type', string='Label Type') + label_ids = fields.One2many( + comodel_name='print.product.label.line', + inverse_name='wizard_id', + string='Labels for Products', + default=_get_product_label_ids, + ) + report_id = fields.Many2one( + comodel_name='ir.actions.report', + string='Label', + domain=[('model', '=', 'print.product.label.line')], + ) + is_template_report = fields.Boolean(compute='_compute_is_template_report') + qty_per_product = fields.Integer( + string='Label quantity per product', + default=1, + ) + # Options + humanreadable = fields.Boolean( + string='Human readable barcode', + help='Print digital code of barcode.', + default=False, + ) + border_width = fields.Integer( + string='Border', + help='Border width for labels (in pixels). Set "0" for no border.' + ) + lang = fields.Selection( + selection=_lang_get, + string='Language', + help="The language that will be used to translate label names.", + ) + company_id = fields.Many2one( + comodel_name='res.company', + help='Specify a company for product labels.' + ) + + @api.depends('report_id') + def _compute_is_template_report(self): + for wizard in self: + # flake8: noqa: E501 + wizard.is_template_report = self.report_id == self.env.ref('garazd_product_label.action_report_product_label_from_template') + + def get_labels_to_print(self): + self.ensure_one() + labels = self.label_ids.filtered(lambda l: l.selected and l.qty) + if not labels: + raise UserError(_('Nothing to print, set the quantity of labels in the table.')) + return labels + + def _get_report_action_params(self): + """Return two params for a report action: record "ids" and "data".""" + self.ensure_one() + return self.get_labels_to_print().ids, None + + def _prepare_report(self): + self.ensure_one() + output_mode = self._context.get('print_mode', 'pdf') + if not self.report_id: + raise UserError(_('Please select a label type.')) + report = self.report_id.with_context(discard_logo_check=True, lang=self.lang) + report.sudo().write({'report_type': f'qweb-{output_mode}'}) + return report + + def action_print(self): + """Print labels.""" + self.ensure_one() + report = self._prepare_report() + return report.report_action(*self._get_report_action_params()) + + def action_set_qty(self): + """Set a specific number of labels for all lines.""" + self.ensure_one() + self.label_ids.write({'qty': self.qty_per_product}) + + def action_restore_initial_qty(self): + """Restore the initial number of labels for all lines.""" + self.ensure_one() + for label in self.label_ids: + if label.qty_initial: + label.update({'qty': label.qty_initial}) + + @api.model + def get_quick_report_action( + self, model_name: str, ids: List[int], qty: int = None, template=None, force_direct: bool = False, + ): + """ Allow to get a report action for custom labels. Method to override. """ + wizard = self.with_context( + **{'active_model': model_name, f'default_{model_name.replace(".", "_")}_ids': ids} + ).create({'report_id': self.env.ref('garazd_product_label.action_report_product_label_50x38').id}) + return wizard.action_print() + + @api.model + def _set_sequence(self, lbl, seq, processed): + if lbl in processed: + return seq, processed + lbl.sequence = seq + seq += 1 + processed += lbl + return seq, processed + + def action_sort_by_product(self): + self.ensure_one() + sequence = 1000 + processed_labels = self.env['print.product.label.line'].browse() + # flake8: noqa: E501 + for label in self.label_ids: + sequence, processed_labels = self._set_sequence(label, sequence, processed_labels) + tmpl_labels = self.label_ids.filtered(lambda l: l.product_id.product_tmpl_id == label.product_id.product_tmpl_id).sorted(lambda l: l.product_id.id, reverse=True) - label + for tmpl_label in tmpl_labels: + sequence, processed_labels = self._set_sequence(tmpl_label, sequence, processed_labels) + product_labels = tmpl_labels.filtered(lambda l: l.product_id == label.product_id) - tmpl_label + for product_label in product_labels: + sequence, processed_labels = self._set_sequence(product_label, sequence, processed_labels) + + def get_pdf(self): + self.ensure_one() + report = self.with_context(print_mode='pdf')._prepare_report() + pdf_data = report._render_qweb_pdf(report, *self._get_report_action_params()) + return base64.b64encode(pdf_data[0]) diff --git a/garazd_product_label/garazd_product_label/wizard/print_product_label_line.py b/garazd_product_label/garazd_product_label/wizard/print_product_label_line.py new file mode 100644 index 0000000..a3704b4 --- /dev/null +++ b/garazd_product_label/garazd_product_label/wizard/print_product_label_line.py @@ -0,0 +1,48 @@ +# Copyright © 2018 Garazd Creation () +# @author: Yurii Razumovskyi () +# @author: Iryna Razumovska () +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html). + +from odoo import api, fields, models + + +class PrintProductLabelLine(models.TransientModel): + _name = "print.product.label.line" + _description = 'Line with a Product Label Data' + _order = 'sequence' + + sequence = fields.Integer(default=900) + selected = fields.Boolean(string='Print', default=True) + wizard_id = fields.Many2one(comodel_name='print.product.label') # Do not make required + product_id = fields.Many2one(comodel_name='product.product', required=True) + barcode = fields.Char(compute='_compute_barcode') + qty_initial = fields.Integer(string='Initial Qty', default=1) + qty = fields.Integer(string='Label Qty', default=1) + custom_value = fields.Char(help="This field can be filled manually to use in label templates.") + company_id = fields.Many2one(comodel_name='res.company', compute='_compute_company_id') + # Allow users to specify a partner to use it on label templates + partner_id = fields.Many2one(comodel_name='res.partner', readonly=False) + + @api.depends('wizard_id.company_id') + def _compute_company_id(self): + for label in self: + label.company_id = label.wizard_id.company_id.id \ + if label.wizard_id.company_id else self.env.user.company_id.id + + @api.depends('product_id') + def _compute_barcode(self): + for label in self: + label.barcode = label.product_id.barcode + + def action_plus_qty(self): + self.ensure_one() + if not self.qty: + self.update({'selected': True}) + self.update({'qty': self.qty + 1}) + + def action_minus_qty(self): + self.ensure_one() + if self.qty > 0: + self.update({'qty': self.qty - 1}) + if not self.qty: + self.update({'selected': False}) diff --git a/garazd_product_label/garazd_product_label/wizard/print_product_label_views.xml b/garazd_product_label/garazd_product_label/wizard/print_product_label_views.xml new file mode 100644 index 0000000..1c03f7d --- /dev/null +++ b/garazd_product_label/garazd_product_label/wizard/print_product_label_views.xml @@ -0,0 +1,108 @@ + + + + + print.product.label.view.form + print.product.label + +
    +
    +
    +
    + + + + + + + + + + + + + +
    +
    + + + + + + + + + + + +
    + + diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/print_product_label_options.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/print_product_label_options.png new file mode 100644 index 0000000..2b53f67 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/print_product_label_options.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/print_product_label_pro_settings.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/print_product_label_pro_settings.png new file mode 100644 index 0000000..558f3ac Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/print_product_label_pro_settings.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_100x100mm.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_100x100mm.png new file mode 100644 index 0000000..12759c3 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_100x100mm.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_100x100mm_multi_price.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_100x100mm_multi_price.png new file mode 100644 index 0000000..77268ab Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_100x100mm_multi_price.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_38x25mm.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_38x25mm.png new file mode 100644 index 0000000..7a6f839 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_38x25mm.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_50x25mm.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_50x25mm.png new file mode 100644 index 0000000..a6f55f7 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_50x25mm.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_A4_63x38mm.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_A4_63x38mm.png new file mode 100644 index 0000000..33f106e Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_A4_63x38mm.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_A4_99x38mm.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_A4_99x38mm.png new file mode 100644 index 0000000..f32bb46 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_A4_99x38mm.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_letter_101x50mm.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_letter_101x50mm.png new file mode 100644 index 0000000..306d60d Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_letter_101x50mm.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_letter_66x25mm.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_letter_66x25mm.png new file mode 100644 index 0000000..9c6cae0 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_barcode_label_letter_66x25mm.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_add_dymo_template.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_add_dymo_template.png new file mode 100644 index 0000000..4f40b0c Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_add_dymo_template.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_add_section.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_add_section.png new file mode 100644 index 0000000..f6a4733 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_add_section.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_add_template.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_add_template.png new file mode 100644 index 0000000..a4f670c Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_add_template.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_direct_print.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_direct_print.png new file mode 100644 index 0000000..88ff142 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_direct_print.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_direct_print_window.jpg b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_direct_print_window.jpg new file mode 100644 index 0000000..cc2fb49 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_direct_print_window.jpg differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_preview.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_preview.png new file mode 100644 index 0000000..7027650 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_preview.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_float.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_float.png new file mode 100644 index 0000000..2b81b5c Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_float.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_list.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_list.png new file mode 100644 index 0000000..7eafb43 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_list.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_settings.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_settings.png new file mode 100644 index 0000000..c453a4c Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_settings.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_settings_data.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_settings_data.png new file mode 100644 index 0000000..8dd771a Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_settings_data.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_settings_design.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_settings_design.png new file mode 100644 index 0000000..da6c0fb Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_settings_design.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_settings_text.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_settings_text.png new file mode 100644 index 0000000..e131656 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_section_settings_text.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_user_default_template.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_user_default_template.png new file mode 100644 index 0000000..1463258 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_builder_user_default_template.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_general_settings.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_general_settings.png new file mode 100644 index 0000000..ef46331 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_general_settings.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_shorten_url.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_shorten_url.png new file mode 100644 index 0000000..2a57b35 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_shorten_url.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_shorten_url_option.png b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_shorten_url_option.png new file mode 100644 index 0000000..966dc17 Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/description/product_label_shorten_url_option.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/static/img/no-barcode.png b/garazd_product_label_pro/garazd_product_label_pro/static/img/no-barcode.png new file mode 100644 index 0000000..dc30cbe Binary files /dev/null and b/garazd_product_label_pro/garazd_product_label_pro/static/img/no-barcode.png differ diff --git a/garazd_product_label_pro/garazd_product_label_pro/tests/__init__.py b/garazd_product_label_pro/garazd_product_label_pro/tests/__init__.py new file mode 100644 index 0000000..34fbe54 --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/tests/__init__.py @@ -0,0 +1 @@ +from . import test_access_rights diff --git a/garazd_product_label_pro/garazd_product_label_pro/tests/common.py b/garazd_product_label_pro/garazd_product_label_pro/tests/common.py new file mode 100644 index 0000000..a6d40ea --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/tests/common.py @@ -0,0 +1,45 @@ +# Copyright © 2023 Garazd Creation (https://garazd.biz) +# @author: Yurii Razumovskyi (support@garazd.biz) +# @author: Iryna Razumovska (support@garazd.biz) +# License OPL-1 (https://www.odoo.com/documentation/16.0/legal/licenses.html). + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestProductLabel(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.label_template_50x25 = cls.env['print.product.label.template'].create({ + 'name': 'Test Label', + 'paperformat_id': cls.env.ref('garazd_product_label_pro.paperformat_label_custom_50x25').id, + 'orientation': 'Portrait', + 'cols': 1, + 'rows': 1, + 'width': 50, + 'height': 25, + }) + cls.product_a = cls.env['product.product'].create({ + 'name': 'Test Product A', + 'type': 'consu', + 'list_price': 20.0, + 'barcode': '1234567890', + }) + cls.product_b = cls.env['product.product'].create({ + 'name': 'Test Product B', + 'type': 'consu', + 'list_price': 199.99, + 'barcode': '9999999999999', + }) + + def setUp(self): + super(TestProductLabel, self).setUp() + + self.print_wizard = self.env['print.product.label'].with_context(**{ + 'active_model': 'product.product', + 'default_product_product_ids': [self.product_a.id, self.product_b.id], + }).create({}) diff --git a/garazd_product_label_pro/garazd_product_label_pro/tests/test_access_rights.py b/garazd_product_label_pro/garazd_product_label_pro/tests/test_access_rights.py new file mode 100644 index 0000000..fb39556 --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/tests/test_access_rights.py @@ -0,0 +1,27 @@ +# Copyright © 2023 Garazd Creation (https://garazd.biz) +# @author: Yurii Razumovskyi (support@garazd.biz) +# @author: Iryna Razumovska (support@garazd.biz) +# License OPL-1 (https://www.odoo.com/documentation/16.0/legal/licenses.html). + +from odoo.tests import tagged + +from odoo.addons.base.tests.common import BaseUsersCommon +from .common import TestProductLabel + + +@tagged('post_install', '-at_install') +class TestAccessRights(BaseUsersCommon, TestProductLabel): + + def test_access_internal_user(self): + """ Test internal user's access rights """ + PrintWizard = self.env['print.product.label'].with_user(self.user_internal) + wizard_as_internal_user = PrintWizard.browse(self.print_wizard.id) + + # Internal user can use label templates + wizard_as_internal_user.read() + + # Internal user can change label templates + wizard_as_internal_user.write({'template_id': self.label_template_50x25.id}) + + # Internal user can preview label templates + wizard_as_internal_user.action_print() diff --git a/garazd_product_label_pro/garazd_product_label_pro/views/print_product_label_section_views.xml b/garazd_product_label_pro/garazd_product_label_pro/views/print_product_label_section_views.xml new file mode 100644 index 0000000..051c933 --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/views/print_product_label_section_views.xml @@ -0,0 +1,215 @@ + + + + + print.product.label.section.view.form + print.product.label.section + +
    + + + +
    +

    + +

    +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + Note: Fonts are not applied on the sample label. Use the PDF Preview to see the actual generated labels. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    + + + print.product.label.section.view.list + print.product.label.section + + + + + + + + + + + + + + + + + + + + + + +
    diff --git a/garazd_product_label_pro/garazd_product_label_pro/views/print_product_label_template_views.xml b/garazd_product_label_pro/garazd_product_label_pro/views/print_product_label_template_views.xml new file mode 100644 index 0000000..5344b32 --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/views/print_product_label_template_views.xml @@ -0,0 +1,158 @@ + + + + + ir.actions.act_window + Sections + print.product.label.section + list,form + {'default_template_id': active_id, 'active_test': False} + [('template_id', '=', active_id)] + + + + print.product.label.template.view.form + print.product.label.template + +
    + +
    + +
    + + + + + +
    +

    +
    + +
    +

    +
    +
    +
    +
    +

    +
    +
    +
    +
    +

    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Be aware specifying custom HTML styles, it can break the label generation!
    + +
    +
    +
    +
    +
    +
    +
    + + + print.product.label.template.view.list + print.product.label.template + + + + + + + + + + + + + + + + + + +
    diff --git a/garazd_product_label_pro/garazd_product_label_pro/views/res_config_settings_views.xml b/garazd_product_label_pro/garazd_product_label_pro/views/res_config_settings_views.xml new file mode 100644 index 0000000..ea027b8 --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/views/res_config_settings_views.xml @@ -0,0 +1,34 @@ + + + + + res.config.settings.view.form.inherit.garazd_product_label_pro + res.config.settings + + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    diff --git a/garazd_product_label_pro/garazd_product_label_pro/views/res_users_views.xml b/garazd_product_label_pro/garazd_product_label_pro/views/res_users_views.xml new file mode 100644 index 0000000..cef1260 --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/views/res_users_views.xml @@ -0,0 +1,19 @@ + + + + + res.users.form.inherit.garazd_product_label_pro + res.users + + + + + + + + + + + + + diff --git a/garazd_product_label_pro/garazd_product_label_pro/views/templates.xml b/garazd_product_label_pro/garazd_product_label_pro/views/templates.xml new file mode 100644 index 0000000..9976e5d --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/views/templates.xml @@ -0,0 +1,69 @@ + + + + + + diff --git a/garazd_product_label_pro/garazd_product_label_pro/wizard/__init__.py b/garazd_product_label_pro/garazd_product_label_pro/wizard/__init__.py new file mode 100644 index 0000000..5a981b6 --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/wizard/__init__.py @@ -0,0 +1,5 @@ +from . import print_product_label +from . import print_product_label_line +from . import print_product_label_template_add +from . import product_label_layout +from . import print_product_label_preview diff --git a/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label.py b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label.py new file mode 100644 index 0000000..f84af0f --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label.py @@ -0,0 +1,216 @@ +# Copyright © 2023 Garazd Creation (https://garazd.biz) +# @author: Yurii Razumovskyi (support@garazd.biz) +# @author: Iryna Razumovska (support@garazd.biz) +# License OPL-1 (https://www.odoo.com/documentation/15.0/legal/licenses.html). + +from typing import List + +from odoo import _, api, Command, fields, models +from odoo.exceptions import UserError + + +class PrintProductLabel(models.TransientModel): + _inherit = "print.product.label" + + report_id = fields.Many2one( + default=lambda self: self.env.ref('garazd_product_label.action_report_product_label_from_template'), + ) + template_id = fields.Many2one( + comodel_name='print.product.label.template', + # flake8: noqa: E501 + default=lambda self: self.env.user.print_label_template_id or self.env['print.product.label.template'].search([], limit=1), + ) + allowed_template_ids = fields.Many2many( + comodel_name='print.product.label.template', + compute='_compute_allowed_template_ids', + help='Technical field to restrict allowed label templates.', + ) + allowed_template_count = fields.Integer(compute='_compute_allowed_template_ids', help='Technical field.') + template_preview_html = fields.Html( + compute='_compute_template_preview_html', + compute_sudo=True, + ) + label_template_preview = fields.Boolean(help='Show Label Template Sample.') + pricelist_id = fields.Many2one( + comodel_name='product.pricelist', + ) + sale_pricelist_id = fields.Many2one( + comodel_name='product.pricelist', + string='Sales Pricelist', + help='Specify this second pricelist to put one more product price to a label.', + ) + skip_place_count = fields.Integer( + string='Skip Places', + default=0, + help='Specify how many places for labels should be skipped on printing. This can' + ' be useful if you are printing on a sheet with labels already printed.', + ) + label_type_id = fields.Many2one( + help='You can filter label templates by selecting their type. It makes sense if you use ' + 'additional extensions to print labels not for products only but for other objects as well. ' + 'Like as Stock Packages, Sales Orders, Manufacturing Orders, etc. ' + '>>> To view available extensions go to the "Actions" menu and click to the "Get Label Extensions".', + # default=lambda self: self.env.ref('garazd_product_label.type_product'), + # required=True, + ) + show_template_limit = fields.Integer(compute='_compute_allowed_template_ids') + + @api.depends('label_type_id') + def _compute_allowed_template_ids(self): + for wizard in self: + user_allowed_templates = self.env['print.product.label.template']._get_user_allowed_templates() + allowed_templates = user_allowed_templates.filtered(lambda lt: lt.type_id == wizard.label_type_id) + ## Add templates without the specified type + # if wizard.mode == 'product.product': + # allowed_templates += user_allowed_templates.filtered(lambda lt: not lt.type_id) + wizard.allowed_template_ids = [Command.set(allowed_templates.ids)] + wizard.allowed_template_count = len(allowed_templates) + # flake8: noqa: E501 + wizard.show_template_limit = self.env['ir.config_parameter'].sudo().get_param('garazd_product_label.show_label_template_limit', 7) + + @api.depends('template_id', 'pricelist_id', 'sale_pricelist_id', 'lang') + def _compute_template_preview_html(self): + for wizard in self: + products = wizard.label_ids.mapped('product_id') + wizard.template_id.with_context(print_product_id=products[:1].id)._compute_preview_html() + wizard.template_preview_html = wizard.with_context(**{ + 'print_wizard_id': wizard.id, # It allows previewing real products on label designing + 'preview_mode': True, # It's used to avoid generating of a shorten URL + 'pricelist_id': wizard.pricelist_id.id, # It's used for previewing on label designing + 'sale_pricelist_id': wizard.sale_pricelist_id.id, # It's used for previewing on label designing + 'lang': wizard.lang or self._context.get('lang'), + }).template_id.preview_html + + @api.onchange('label_type_id') + def _onchange_label_type_id(self): + for wizard in self: + user_template = self.env.user.print_label_template_id + if user_template and user_template.id in wizard.allowed_template_ids.ids: + wizard.template_id = user_template.id + else: + wizard.template_id = wizard.allowed_template_ids[0].id if wizard.allowed_template_ids else False + wizard._compute_template_preview_html() + + def _get_label_data(self): + self.ensure_one() + labels = self.get_labels_to_print() + if not self.is_template_report: + return {'ids': labels.ids, 'data': {}} + if not self.template_id: + raise UserError(_('Select the label template to print.')) + self.template_id._set_paperformat() + label_data = { + 'ids': labels.ids, + 'data': { + 'rows': self.template_id.rows, + 'cols': self.template_id.cols, + 'row_gap': self.template_id.row_gap, + 'col_gap': self.template_id.col_gap, + 'label_style': + 'overflow: hidden;' + 'font-family: "Arial";' + 'width: %(width).2fmm;' + 'height: %(height).2fmm;' + 'padding: %(padding_top).2fmm %(padding_right).2fmm' + ' %(padding_bottom).2fmm %(padding_left).2fmm;' + 'border: %(border)s;' + '%(custom_style)s' % { + 'width': self.template_id.width, + 'height': self.template_id.height, + 'padding_top': self.template_id.padding_top, + 'padding_right': self.template_id.padding_right, + 'padding_bottom': self.template_id.padding_bottom, + 'padding_left': self.template_id.padding_left, + 'border': "%dpx solid #EEE" % self.border_width + if self.border_width else 0, + 'custom_style': self.template_id.label_style or '', + }, + 'skip_places': self.skip_place_count, + }, + } + # Add extra styles for multi labels + if self.template_id.cols != 1 or self.template_id.rows != 1: + label_data['data']['label_style'] += 'float: left;' + return label_data + + def _get_report_action_params(self): + ids, data = super(PrintProductLabel, self)._get_report_action_params() + if self.is_template_report: + ids = None + data = self._get_label_data() + return ids, data + + @api.model + def get_quick_report_action( + self, model_name: str, ids: List[int], qty: int = None, template=None, + force_direct: bool = False, close_window: bool = False, + ): + """ Overwritten completely to use with custom label templates. """ + template = template or self.env.user.print_label_template_id + if not template: + raise UserError(_('Specify a label template for the current user to print custom labels.')) + wizard = self.with_context(**{ + 'active_model': model_name, + f'default_{model_name.replace(".", "_")}_ids': ids, + }).create({ + 'report_id': self.env.ref('garazd_product_label.action_report_product_label_from_template').id, + 'template_id': template.id, + }) + + if isinstance(qty, int): + wizard.label_ids.write({'qty': qty, 'qty_initial': qty}) + + report_action = wizard.action_print() + if close_window: + report_action.update({'close_on_report_download': True}) + + return wizard.action_print_direct() if self.env.user.print_label_directly or force_direct else report_action + + def action_add_template(self): + self.ensure_one() + return { + 'name': _('Add a New Label Template'), + 'type': 'ir.actions.act_window', + 'res_model': 'print.product.label.template.add', + 'view_mode': 'form', + 'target': 'new', + } + + def action_edit_template(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'res_model': self.template_id._name, + 'res_id': self.template_id.id, + 'view_mode': 'form', + } + + def action_reset_skip(self): + """Reset the skip empty places count value. """ + self.ensure_one() + self.write({'skip_place_count': 0}) + + @api.model + def open_extension_app_list(self): + return { + 'type': 'ir.actions.act_url', + 'url': 'https://apps.odoo.com/apps/browse?repo_maintainer_id=119796&search=garazd_product_label_', + 'target': 'new', + 'target_type': 'public', + } + + @api.model + def _pdf_preview(self, label_data: bytes): + preview = self.env['print.product.label.preview'].sudo().create({'label_pdf': label_data}) + return { + 'name': _('Label Preview'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': preview._name, + 'res_id': preview.id, + 'target': 'new', + } + + def action_pdf_preview(self): + self.ensure_one() + return self._pdf_preview(self.get_pdf()) diff --git a/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_line.py b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_line.py new file mode 100644 index 0000000..e25f1de --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_line.py @@ -0,0 +1,34 @@ +# Copyright © 2022 Garazd Creation (https://garazd.biz) +# @author: Yurii Razumovskyi (support@garazd.biz) +# @author: Iryna Razumovska (support@garazd.biz) +# License OPL-1 (https://www.odoo.com/documentation/15.0/legal/licenses.html). + +from odoo import api, fields, models + + +class PrintProductLabelLine(models.TransientModel): + _inherit = "print.product.label.line" + + price = fields.Float(digits='Product Price', compute='_compute_product_price') + currency_id = fields.Many2one(comodel_name='res.currency', compute='_compute_product_price') + promo_price = fields.Float(digits='Product Price', compute='_compute_product_price') + promo_currency_id = fields.Many2one(comodel_name='res.currency', compute='_compute_product_price') + + @api.depends('product_id', 'wizard_id.pricelist_id', 'wizard_id.sale_pricelist_id') + def _compute_product_price(self): + # When we add a new line by UI in the wizard form, the line doesn't + # have a product. So we calculate prices only for lines with products + with_product = self.filtered('product_id') + for line in with_product: + # flake8: noqa: E501 + pricelist = line.wizard_id.pricelist_id + line.price = pricelist._get_product_price(line.product_id, 1.0) if pricelist else line.product_id.lst_price + line.currency_id = pricelist.currency_id.id if pricelist else line.product_id.currency_id.id + promo_pricelist = line.wizard_id.sale_pricelist_id + line.promo_price = promo_pricelist._get_product_price(line.product_id, 1.0) if promo_pricelist else line.price + line.promo_currency_id = promo_pricelist.currency_id.id if promo_pricelist else line.currency_id.id + + (self - with_product).price = False + (self - with_product).currency_id = False + (self - with_product).promo_price = False + (self - with_product).promo_currency_id = False diff --git a/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_preview.py b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_preview.py new file mode 100644 index 0000000..e60f5ac --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_preview.py @@ -0,0 +1,13 @@ +# Copyright © 2024 Garazd Creation (https://garazd.biz) +# @author: Yurii Razumovskyi (support@garazd.biz) +# @author: Iryna Razumovska (support@garazd.biz) +# License OPL-1 (https://www.odoo.com/documentation/17.0/legal/licenses.html). + +from odoo import fields, models + + +class PrintProductLabelPreview(models.TransientModel): + _name = "print.product.label.preview" + _description = "Preview Labels in PDF" + + label_pdf = fields.Binary(string='PDF', readonly=True) diff --git a/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_preview_views.xml b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_preview_views.xml new file mode 100644 index 0000000..085b053 --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_preview_views.xml @@ -0,0 +1,17 @@ + + + + + print.product.label.preview.form + print.product.label.preview + +
    + +
    +
    + +
    +
    + +
    diff --git a/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_template_add.py b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_template_add.py new file mode 100644 index 0000000..aa6409a --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_template_add.py @@ -0,0 +1,115 @@ +# Copyright © 2022 Garazd Creation (https://garazd.biz) +# @author: Yurii Razumovskyi (support@garazd.biz) +# @author: Iryna Razumovska (support@garazd.biz) +# License OPL-1 (https://www.odoo.com/documentation/16.0/legal/licenses.html). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class PrintProductLabelTemplateAdd(models.TransientModel): + _name = "print.product.label.template.add" + _description = 'Wizard to add a new product label templates' + + type_id = fields.Many2one( + comodel_name='print.label.type', + string='Label Type', + default=lambda self: self.env.ref('garazd_product_label.type_product'), + required=True, + ) + width = fields.Integer(help='Label Width in mm.', required=True) + height = fields.Integer(help='Label Height in mm.', required=True) + rows = fields.Integer(default=1, required=True) + cols = fields.Integer(default=1, required=True) + paper_format = fields.Selection( + selection=[ + ('custom', 'Custom'), + ('A4', 'A4'), + ('Letter', 'US Letter'), + ], + help="Select Proper Paper size", + default='custom', + required=True, + ) + orientation = fields.Selection( + selection=[ + ('Portrait', 'Portrait'), + ('Landscape', 'Landscape'), + ], + default='Portrait', + required=True, + ) + page_width = fields.Integer(help='Page Width in mm.') + page_height = fields.Integer(help='Page Height in mm.') + + @api.constrains('rows', 'cols', 'width', 'height') + def _check_page_layout(self): + for wizard in self: + if not (wizard.width and wizard.height): + raise ValidationError(_('The label sizes must be set.')) + if not (wizard.cols and wizard.rows): + raise ValidationError( + _('The page layout values "Cols" and "Rows" must be set.')) + if wizard.paper_format == 'custom' and wizard._is_multi_layout(): + if not (self.page_width or self.page_height): + raise ValidationError( + _('The page sizes "Page Width" and "Page Height" must be set.')) + if self.page_width < self.width: + raise ValidationError( + _('The page width must be not less than label width.')) + if self.page_height < self.height: + raise ValidationError( + _('The page height must be not less than label height.')) + + def _is_multi_layout(self): + self.ensure_one() + return self.cols > 1 or self.rows > 1 + + def _get_label_name(self): + self.ensure_one() + # flake8: noqa: E501 + paperformat_name = 'Custom' if self.paper_format == 'custom' else self.paper_format + page_sizes = f" {self.page_width}x{self.page_height} mm" if self.page_width and self.page_height else "" + layout_name = f" ({paperformat_name}{page_sizes}: {self.cols * self.rows} pcs, {self.cols}x{self.rows})" if self.paper_format != "custom" or self._is_multi_layout() else "" + return f'Label: {self.width}x{self.height} mm{layout_name}' + + def _create_paperformat(self): + self.ensure_one() + return self.env['report.paperformat'].sudo().create({ + 'name': self._get_label_name(), + 'format': self.paper_format, + 'page_width': 0 if self.paper_format != 'custom' + else self.page_width if self._is_multi_layout() + else self.width, + 'page_height': 0 if self.paper_format != 'custom' + else self.page_height if self._is_multi_layout() + else self.height, + 'orientation': self.orientation, + 'margin_top': 0, + 'margin_bottom': 0, + 'margin_left': 0, + 'margin_right': 0, + 'header_spacing': 0, + 'header_line': False, + 'disable_shrinking': True, + 'dpi': 96, + 'default': False, + }) + + def action_create(self): + self.ensure_one() + template = self.env['print.product.label.template'].create({ + 'type_id': self.type_id.id, + 'name': self._get_label_name().replace(':', '', 1), + 'paperformat_id': self._create_paperformat().id, + 'width': self.width, + 'height': self.height, + 'rows': self.rows, + 'cols': self.cols, + }) + return { + 'type': 'ir.actions.act_window', + 'res_model': template._name, + 'res_id': template.id, + 'view_mode': 'form', + } diff --git a/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_template_add_views.xml b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_template_add_views.xml new file mode 100644 index 0000000..b70bf4d --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_template_add_views.xml @@ -0,0 +1,57 @@ + + + + + print.product.label.template.add.view.form + print.product.label.template.add + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    + +
    diff --git a/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_views.xml b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_views.xml new file mode 100644 index 0000000..982f2f9 --- /dev/null +++ b/garazd_product_label_pro/garazd_product_label_pro/wizard/print_product_label_views.xml @@ -0,0 +1,94 @@ + + + + + Label Templates + print.product.label.template + list,form + {'active_test': False} + + + + + + print.product.label.view.form.inherit.garazd_product_label_pro + print.product.label + + + + +
    + + + + +
    + + + + +
    +
    + + + +
    + +
    + +
    + + + + +
    +
    +
    +
    + +
    + +
    + +
    + +
    + + +
    +
    + + + + + + + + + +
    + + +
    +
    +
    + +
    + +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + + + + + +
    +
    +
    + + + + + + + +
    + + diff --git a/pdf_print_preview/pdf_print_preview/static/lib/pdfjs/web/viewer.html_Zone.Identifier b/pdf_print_preview/pdf_print_preview/static/lib/pdfjs/web/viewer.html_Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/pdf_print_preview/pdf_print_preview/static/lib/pdfjs/web/viewer.js b/pdf_print_preview/pdf_print_preview/static/lib/pdfjs/web/viewer.js new file mode 100644 index 0000000..6426ed3 --- /dev/null +++ b/pdf_print_preview/pdf_print_preview/static/lib/pdfjs/web/viewer.js @@ -0,0 +1,15563 @@ +/** + * @licstart The following is the entire license notice for the + * Javascript code in this page + * + * Copyright 2018 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @licend The above is the entire license notice for the + * Javascript code in this page + */ + +/******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); +/******/ } +/******/ }; +/******/ +/******/ // define __esModule on exports +/******/ __webpack_require__.r = function(exports) { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ +/******/ // create a fake namespace object +/******/ // mode & 1: value is a module id, require it +/******/ // mode & 2: merge all properties of value into the ns +/******/ // mode & 4: return value when already ns object +/******/ // mode & 8|1: behave like require +/******/ __webpack_require__.t = function(value, mode) { +/******/ if(mode & 1) value = __webpack_require__(value); +/******/ if(mode & 8) return value; +/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; +/******/ var ns = Object.create(null); +/******/ __webpack_require__.r(ns); +/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); +/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); +/******/ return ns; +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; +/******/ +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = 0); +/******/ }) +/************************************************************************/ +/******/ ([ +/* 0 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +; +var pdfjsWebApp, pdfjsWebAppOptions; +{ + pdfjsWebApp = __webpack_require__(1); + pdfjsWebAppOptions = __webpack_require__(12); +} +; +{ + __webpack_require__(37); +} +; +{ + __webpack_require__(42); +} + +function getViewerConfiguration() { + return { + appContainer: document.body, + mainContainer: document.getElementById('viewerContainer'), + viewerContainer: document.getElementById('viewer'), + eventBus: null, + toolbar: { + container: document.getElementById('toolbarViewer'), + numPages: document.getElementById('numPages'), + pageNumber: document.getElementById('pageNumber'), + scaleSelectContainer: document.getElementById('scaleSelectContainer'), + scaleSelect: document.getElementById('scaleSelect'), + customScaleOption: document.getElementById('customScaleOption'), + previous: document.getElementById('previous'), + next: document.getElementById('next'), + zoomIn: document.getElementById('zoomIn'), + zoomOut: document.getElementById('zoomOut'), + viewFind: document.getElementById('viewFind'), + openFile: document.getElementById('openFile'), + print: document.getElementById('print'), + presentationModeButton: document.getElementById('presentationMode'), + download: document.getElementById('download'), + viewBookmark: document.getElementById('viewBookmark') + }, + secondaryToolbar: { + toolbar: document.getElementById('secondaryToolbar'), + toggleButton: document.getElementById('secondaryToolbarToggle'), + toolbarButtonContainer: document.getElementById('secondaryToolbarButtonContainer'), + presentationModeButton: document.getElementById('secondaryPresentationMode'), + openFileButton: document.getElementById('secondaryOpenFile'), + printButton: document.getElementById('secondaryPrint'), + downloadButton: document.getElementById('secondaryDownload'), + viewBookmarkButton: document.getElementById('secondaryViewBookmark'), + firstPageButton: document.getElementById('firstPage'), + lastPageButton: document.getElementById('lastPage'), + pageRotateCwButton: document.getElementById('pageRotateCw'), + pageRotateCcwButton: document.getElementById('pageRotateCcw'), + cursorSelectToolButton: document.getElementById('cursorSelectTool'), + cursorHandToolButton: document.getElementById('cursorHandTool'), + scrollVerticalButton: document.getElementById('scrollVertical'), + scrollHorizontalButton: document.getElementById('scrollHorizontal'), + scrollWrappedButton: document.getElementById('scrollWrapped'), + spreadNoneButton: document.getElementById('spreadNone'), + spreadOddButton: document.getElementById('spreadOdd'), + spreadEvenButton: document.getElementById('spreadEven'), + documentPropertiesButton: document.getElementById('documentProperties') + }, + fullscreen: { + contextFirstPage: document.getElementById('contextFirstPage'), + contextLastPage: document.getElementById('contextLastPage'), + contextPageRotateCw: document.getElementById('contextPageRotateCw'), + contextPageRotateCcw: document.getElementById('contextPageRotateCcw') + }, + sidebar: { + outerContainer: document.getElementById('outerContainer'), + viewerContainer: document.getElementById('viewerContainer'), + toggleButton: document.getElementById('sidebarToggle'), + thumbnailButton: document.getElementById('viewThumbnail'), + outlineButton: document.getElementById('viewOutline'), + attachmentsButton: document.getElementById('viewAttachments'), + thumbnailView: document.getElementById('thumbnailView'), + outlineView: document.getElementById('outlineView'), + attachmentsView: document.getElementById('attachmentsView') + }, + sidebarResizer: { + outerContainer: document.getElementById('outerContainer'), + resizer: document.getElementById('sidebarResizer') + }, + findBar: { + bar: document.getElementById('findbar'), + toggleButton: document.getElementById('viewFind'), + findField: document.getElementById('findInput'), + highlightAllCheckbox: document.getElementById('findHighlightAll'), + caseSensitiveCheckbox: document.getElementById('findMatchCase'), + entireWordCheckbox: document.getElementById('findEntireWord'), + findMsg: document.getElementById('findMsg'), + findResultsCount: document.getElementById('findResultsCount'), + findPreviousButton: document.getElementById('findPrevious'), + findNextButton: document.getElementById('findNext') + }, + passwordOverlay: { + overlayName: 'passwordOverlay', + container: document.getElementById('passwordOverlay'), + label: document.getElementById('passwordText'), + input: document.getElementById('password'), + submitButton: document.getElementById('passwordSubmit'), + cancelButton: document.getElementById('passwordCancel') + }, + documentProperties: { + overlayName: 'documentPropertiesOverlay', + container: document.getElementById('documentPropertiesOverlay'), + closeButton: document.getElementById('documentPropertiesClose'), + fields: { + 'fileName': document.getElementById('fileNameField'), + 'fileSize': document.getElementById('fileSizeField'), + 'title': document.getElementById('titleField'), + 'author': document.getElementById('authorField'), + 'subject': document.getElementById('subjectField'), + 'keywords': document.getElementById('keywordsField'), + 'creationDate': document.getElementById('creationDateField'), + 'modificationDate': document.getElementById('modificationDateField'), + 'creator': document.getElementById('creatorField'), + 'producer': document.getElementById('producerField'), + 'version': document.getElementById('versionField'), + 'pageCount': document.getElementById('pageCountField'), + 'pageSize': document.getElementById('pageSizeField'), + 'linearized': document.getElementById('linearizedField') + } + }, + errorWrapper: { + container: document.getElementById('errorWrapper'), + errorMessage: document.getElementById('errorMessage'), + closeButton: document.getElementById('errorClose'), + errorMoreInfo: document.getElementById('errorMoreInfo'), + moreInfoButton: document.getElementById('errorShowMore'), + lessInfoButton: document.getElementById('errorShowLess') + }, + printContainer: document.getElementById('printContainer'), + openFileInputName: 'fileInput', + debuggerScriptPath: './debugger.js' + }; +} + +function webViewerLoad() { + var config = getViewerConfiguration(); + window.PDFViewerApplication = pdfjsWebApp.PDFViewerApplication; + window.PDFViewerApplicationOptions = pdfjsWebAppOptions.AppOptions; + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('webviewerloaded', true, true, {}); + document.dispatchEvent(event); + pdfjsWebApp.PDFViewerApplication.run(config); +} + +if (document.readyState === 'interactive' || document.readyState === 'complete') { + webViewerLoad(); +} else { + document.addEventListener('DOMContentLoaded', webViewerLoad, true); +} + +/***/ }), +/* 1 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFPrintServiceFactory = exports.DefaultExternalServices = exports.PDFViewerApplication = void 0; + +var _regenerator = _interopRequireDefault(__webpack_require__(2)); + +var _ui_utils = __webpack_require__(6); + +var _pdfjsLib = __webpack_require__(7); + +var _pdf_cursor_tools = __webpack_require__(8); + +var _pdf_rendering_queue = __webpack_require__(10); + +var _pdf_sidebar = __webpack_require__(11); + +var _app_options = __webpack_require__(12); + +var _overlay_manager = __webpack_require__(14); + +var _password_prompt = __webpack_require__(15); + +var _pdf_attachment_viewer = __webpack_require__(16); + +var _pdf_document_properties = __webpack_require__(17); + +var _pdf_find_bar = __webpack_require__(18); + +var _pdf_find_controller = __webpack_require__(19); + +var _pdf_history = __webpack_require__(21); + +var _pdf_link_service = __webpack_require__(22); + +var _pdf_outline_viewer = __webpack_require__(23); + +var _pdf_presentation_mode = __webpack_require__(24); + +var _pdf_sidebar_resizer = __webpack_require__(25); + +var _pdf_thumbnail_viewer = __webpack_require__(26); + +var _pdf_viewer = __webpack_require__(28); + +var _secondary_toolbar = __webpack_require__(33); + +var _toolbar = __webpack_require__(35); + +var _view_history = __webpack_require__(36); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); } + +function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } + +function _iterableToArrayLimit(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } + +function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } + +function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } + +function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; } + +var DEFAULT_SCALE_DELTA = 1.1; +var DISABLE_AUTO_FETCH_LOADING_BAR_TIMEOUT = 5000; +var FORCE_PAGES_LOADED_TIMEOUT = 10000; +var WHEEL_ZOOM_DISABLED_TIMEOUT = 1000; +var ViewOnLoad = { + UNKNOWN: -1, + PREVIOUS: 0, + INITIAL: 1 +}; +var DefaultExternalServices = { + updateFindControlState: function updateFindControlState(data) {}, + updateFindMatchesCount: function updateFindMatchesCount(data) {}, + initPassiveLoading: function initPassiveLoading(callbacks) {}, + fallback: function fallback(data, callback) {}, + reportTelemetry: function reportTelemetry(data) {}, + createDownloadManager: function createDownloadManager(options) { + throw new Error('Not implemented: createDownloadManager'); + }, + createPreferences: function createPreferences() { + throw new Error('Not implemented: createPreferences'); + }, + createL10n: function createL10n(options) { + throw new Error('Not implemented: createL10n'); + }, + supportsIntegratedFind: false, + supportsDocumentFonts: true, + supportsDocumentColors: true, + supportedMouseWheelZoomModifierKeys: { + ctrlKey: true, + metaKey: true + } +}; +exports.DefaultExternalServices = DefaultExternalServices; +var PDFViewerApplication = { + initialBookmark: document.location.hash.substring(1), + initialized: false, + fellback: false, + appConfig: null, + pdfDocument: null, + pdfLoadingTask: null, + printService: null, + pdfViewer: null, + pdfThumbnailViewer: null, + pdfRenderingQueue: null, + pdfPresentationMode: null, + pdfDocumentProperties: null, + pdfLinkService: null, + pdfHistory: null, + pdfSidebar: null, + pdfSidebarResizer: null, + pdfOutlineViewer: null, + pdfAttachmentViewer: null, + pdfCursorTools: null, + store: null, + downloadManager: null, + overlayManager: null, + preferences: null, + toolbar: null, + secondaryToolbar: null, + eventBus: null, + l10n: null, + isInitialViewSet: false, + downloadComplete: false, + isViewerEmbedded: window.parent !== window, + url: '', + baseUrl: '', + externalServices: DefaultExternalServices, + _boundEvents: {}, + contentDispositionFilename: null, + initialize: function () { + var _initialize = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee(appConfig) { + var _this = this; + + var appContainer; + return _regenerator.default.wrap(function _callee$(_context) { + while (1) { + switch (_context.prev = _context.next) { + case 0: + this.preferences = this.externalServices.createPreferences(); + this.appConfig = appConfig; + _context.next = 4; + return this._readPreferences(); + + case 4: + _context.next = 6; + return this._parseHashParameters(); + + case 6: + _context.next = 8; + return this._initializeL10n(); + + case 8: + if (this.isViewerEmbedded && _app_options.AppOptions.get('externalLinkTarget') === _pdfjsLib.LinkTarget.NONE) { + _app_options.AppOptions.set('externalLinkTarget', _pdfjsLib.LinkTarget.TOP); + } + + _context.next = 11; + return this._initializeViewerComponents(); + + case 11: + this.bindEvents(); + this.bindWindowEvents(); + appContainer = appConfig.appContainer || document.documentElement; + this.l10n.translate(appContainer).then(function () { + _this.eventBus.dispatch('localized', { + source: _this + }); + }); + this.initialized = true; + + case 16: + case "end": + return _context.stop(); + } + } + }, _callee, this); + })); + + function initialize(_x) { + return _initialize.apply(this, arguments); + } + + return initialize; + }(), + _readPreferences: function () { + var _readPreferences2 = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee2() { + var prefs, name; + return _regenerator.default.wrap(function _callee2$(_context2) { + while (1) { + switch (_context2.prev = _context2.next) { + case 0: + if (!(_app_options.AppOptions.get('disablePreferences') === true)) { + _context2.next = 2; + break; + } + + return _context2.abrupt("return"); + + case 2: + _context2.prev = 2; + _context2.next = 5; + return this.preferences.getAll(); + + case 5: + prefs = _context2.sent; + + for (name in prefs) { + _app_options.AppOptions.set(name, prefs[name]); + } + + _context2.next = 11; + break; + + case 9: + _context2.prev = 9; + _context2.t0 = _context2["catch"](2); + + case 11: + case "end": + return _context2.stop(); + } + } + }, _callee2, this, [[2, 9]]); + })); + + function _readPreferences() { + return _readPreferences2.apply(this, arguments); + } + + return _readPreferences; + }(), + _parseHashParameters: function () { + var _parseHashParameters2 = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee3() { + var waitOn, hash, hashParams, viewer, enabled; + return _regenerator.default.wrap(function _callee3$(_context3) { + while (1) { + switch (_context3.prev = _context3.next) { + case 0: + if (_app_options.AppOptions.get('pdfBugEnabled')) { + _context3.next = 2; + break; + } + + return _context3.abrupt("return"); + + case 2: + waitOn = []; + hash = document.location.hash.substring(1); + hashParams = (0, _ui_utils.parseQueryString)(hash); + + if ('disableworker' in hashParams && hashParams['disableworker'] === 'true') { + waitOn.push(loadFakeWorker()); + } + + if ('disablerange' in hashParams) { + _app_options.AppOptions.set('disableRange', hashParams['disablerange'] === 'true'); + } + + if ('disablestream' in hashParams) { + _app_options.AppOptions.set('disableStream', hashParams['disablestream'] === 'true'); + } + + if ('disableautofetch' in hashParams) { + _app_options.AppOptions.set('disableAutoFetch', hashParams['disableautofetch'] === 'true'); + } + + if ('disablefontface' in hashParams) { + _app_options.AppOptions.set('disableFontFace', hashParams['disablefontface'] === 'true'); + } + + if ('disablehistory' in hashParams) { + _app_options.AppOptions.set('disableHistory', hashParams['disablehistory'] === 'true'); + } + + if ('webgl' in hashParams) { + _app_options.AppOptions.set('enableWebGL', hashParams['webgl'] === 'true'); + } + + if ('useonlycsszoom' in hashParams) { + _app_options.AppOptions.set('useOnlyCssZoom', hashParams['useonlycsszoom'] === 'true'); + } + + if ('verbosity' in hashParams) { + _app_options.AppOptions.set('verbosity', hashParams['verbosity'] | 0); + } + + if (!('textlayer' in hashParams)) { + _context3.next = 23; + break; + } + + _context3.t0 = hashParams['textlayer']; + _context3.next = _context3.t0 === 'off' ? 18 : _context3.t0 === 'visible' ? 20 : _context3.t0 === 'shadow' ? 20 : _context3.t0 === 'hover' ? 20 : 23; + break; + + case 18: + _app_options.AppOptions.set('textLayerMode', _ui_utils.TextLayerMode.DISABLE); + + return _context3.abrupt("break", 23); + + case 20: + viewer = this.appConfig.viewerContainer; + viewer.classList.add('textLayer-' + hashParams['textlayer']); + return _context3.abrupt("break", 23); + + case 23: + if ('pdfbug' in hashParams) { + _app_options.AppOptions.set('pdfBug', true); + + enabled = hashParams['pdfbug'].split(','); + waitOn.push(loadAndEnablePDFBug(enabled)); + } + + if ('locale' in hashParams) { + _app_options.AppOptions.set('locale', hashParams['locale']); + } + + return _context3.abrupt("return", Promise.all(waitOn).catch(function (reason) { + console.error("_parseHashParameters: \"".concat(reason.message, "\".")); + })); + + case 26: + case "end": + return _context3.stop(); + } + } + }, _callee3, this); + })); + + function _parseHashParameters() { + return _parseHashParameters2.apply(this, arguments); + } + + return _parseHashParameters; + }(), + _initializeL10n: function () { + var _initializeL10n2 = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee4() { + var dir; + return _regenerator.default.wrap(function _callee4$(_context4) { + while (1) { + switch (_context4.prev = _context4.next) { + case 0: + this.l10n = this.externalServices.createL10n({ + locale: _app_options.AppOptions.get('locale') + }); + _context4.next = 3; + return this.l10n.getDirection(); + + case 3: + dir = _context4.sent; + document.getElementsByTagName('html')[0].dir = dir; + + case 5: + case "end": + return _context4.stop(); + } + } + }, _callee4, this); + })); + + function _initializeL10n() { + return _initializeL10n2.apply(this, arguments); + } + + return _initializeL10n; + }(), + _initializeViewerComponents: function () { + var _initializeViewerComponents2 = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee5() { + var appConfig, dispatchToDOM, eventBus, pdfRenderingQueue, pdfLinkService, downloadManager, findController, container, viewer, thumbnailContainer, sidebarConfig; + return _regenerator.default.wrap(function _callee5$(_context5) { + while (1) { + switch (_context5.prev = _context5.next) { + case 0: + appConfig = this.appConfig; + this.overlayManager = new _overlay_manager.OverlayManager(); + dispatchToDOM = _app_options.AppOptions.get('eventBusDispatchToDOM'); + eventBus = appConfig.eventBus || (0, _ui_utils.getGlobalEventBus)(dispatchToDOM); + this.eventBus = eventBus; + pdfRenderingQueue = new _pdf_rendering_queue.PDFRenderingQueue(); + pdfRenderingQueue.onIdle = this.cleanup.bind(this); + this.pdfRenderingQueue = pdfRenderingQueue; + pdfLinkService = new _pdf_link_service.PDFLinkService({ + eventBus: eventBus, + externalLinkTarget: _app_options.AppOptions.get('externalLinkTarget'), + externalLinkRel: _app_options.AppOptions.get('externalLinkRel') + }); + this.pdfLinkService = pdfLinkService; + downloadManager = this.externalServices.createDownloadManager({ + disableCreateObjectURL: _app_options.AppOptions.get('disableCreateObjectURL') + }); + this.downloadManager = downloadManager; + findController = new _pdf_find_controller.PDFFindController({ + linkService: pdfLinkService, + eventBus: eventBus + }); + this.findController = findController; + container = appConfig.mainContainer; + viewer = appConfig.viewerContainer; + this.pdfViewer = new _pdf_viewer.PDFViewer({ + container: container, + viewer: viewer, + eventBus: eventBus, + renderingQueue: pdfRenderingQueue, + linkService: pdfLinkService, + downloadManager: downloadManager, + findController: findController, + renderer: _app_options.AppOptions.get('renderer'), + enableWebGL: _app_options.AppOptions.get('enableWebGL'), + l10n: this.l10n, + textLayerMode: _app_options.AppOptions.get('textLayerMode'), + imageResourcesPath: _app_options.AppOptions.get('imageResourcesPath'), + renderInteractiveForms: _app_options.AppOptions.get('renderInteractiveForms'), + enablePrintAutoRotate: _app_options.AppOptions.get('enablePrintAutoRotate'), + useOnlyCssZoom: _app_options.AppOptions.get('useOnlyCssZoom'), + maxCanvasPixels: _app_options.AppOptions.get('maxCanvasPixels') + }); + pdfRenderingQueue.setViewer(this.pdfViewer); + pdfLinkService.setViewer(this.pdfViewer); + thumbnailContainer = appConfig.sidebar.thumbnailView; + this.pdfThumbnailViewer = new _pdf_thumbnail_viewer.PDFThumbnailViewer({ + container: thumbnailContainer, + renderingQueue: pdfRenderingQueue, + linkService: pdfLinkService, + l10n: this.l10n + }); + pdfRenderingQueue.setThumbnailViewer(this.pdfThumbnailViewer); + this.pdfHistory = new _pdf_history.PDFHistory({ + linkService: pdfLinkService, + eventBus: eventBus + }); + pdfLinkService.setHistory(this.pdfHistory); + this.findBar = new _pdf_find_bar.PDFFindBar(appConfig.findBar, eventBus, this.l10n); + this.pdfDocumentProperties = new _pdf_document_properties.PDFDocumentProperties(appConfig.documentProperties, this.overlayManager, eventBus, this.l10n); + this.pdfCursorTools = new _pdf_cursor_tools.PDFCursorTools({ + container: container, + eventBus: eventBus, + cursorToolOnLoad: _app_options.AppOptions.get('cursorToolOnLoad') + }); + this.toolbar = new _toolbar.Toolbar(appConfig.toolbar, eventBus, this.l10n); + this.secondaryToolbar = new _secondary_toolbar.SecondaryToolbar(appConfig.secondaryToolbar, container, eventBus); + + if (this.supportsFullscreen) { + this.pdfPresentationMode = new _pdf_presentation_mode.PDFPresentationMode({ + container: container, + viewer: viewer, + pdfViewer: this.pdfViewer, + eventBus: eventBus, + contextMenuItems: appConfig.fullscreen + }); + } + + this.passwordPrompt = new _password_prompt.PasswordPrompt(appConfig.passwordOverlay, this.overlayManager, this.l10n); + this.pdfOutlineViewer = new _pdf_outline_viewer.PDFOutlineViewer({ + container: appConfig.sidebar.outlineView, + eventBus: eventBus, + linkService: pdfLinkService + }); + this.pdfAttachmentViewer = new _pdf_attachment_viewer.PDFAttachmentViewer({ + container: appConfig.sidebar.attachmentsView, + eventBus: eventBus, + downloadManager: downloadManager + }); + sidebarConfig = Object.create(appConfig.sidebar); + sidebarConfig.pdfViewer = this.pdfViewer; + sidebarConfig.pdfThumbnailViewer = this.pdfThumbnailViewer; + this.pdfSidebar = new _pdf_sidebar.PDFSidebar(sidebarConfig, eventBus, this.l10n); + this.pdfSidebar.onToggled = this.forceRendering.bind(this); + this.pdfSidebarResizer = new _pdf_sidebar_resizer.PDFSidebarResizer(appConfig.sidebarResizer, eventBus, this.l10n); + + case 39: + case "end": + return _context5.stop(); + } + } + }, _callee5, this); + })); + + function _initializeViewerComponents() { + return _initializeViewerComponents2.apply(this, arguments); + } + + return _initializeViewerComponents; + }(), + run: function run(config) { + this.initialize(config).then(webViewerInitialized); + }, + zoomIn: function zoomIn(ticks) { + var newScale = this.pdfViewer.currentScale; + + do { + newScale = (newScale * DEFAULT_SCALE_DELTA).toFixed(2); + newScale = Math.ceil(newScale * 10) / 10; + newScale = Math.min(_ui_utils.MAX_SCALE, newScale); + } while (--ticks > 0 && newScale < _ui_utils.MAX_SCALE); + + this.pdfViewer.currentScaleValue = newScale; + }, + zoomOut: function zoomOut(ticks) { + var newScale = this.pdfViewer.currentScale; + + do { + newScale = (newScale / DEFAULT_SCALE_DELTA).toFixed(2); + newScale = Math.floor(newScale * 10) / 10; + newScale = Math.max(_ui_utils.MIN_SCALE, newScale); + } while (--ticks > 0 && newScale > _ui_utils.MIN_SCALE); + + this.pdfViewer.currentScaleValue = newScale; + }, + + get pagesCount() { + return this.pdfDocument ? this.pdfDocument.numPages : 0; + }, + + set page(val) { + this.pdfViewer.currentPageNumber = val; + }, + + get page() { + return this.pdfViewer.currentPageNumber; + }, + + get printing() { + return !!this.printService; + }, + + get supportsPrinting() { + return PDFPrintServiceFactory.instance.supportsPrinting; + }, + + get supportsFullscreen() { + var support; + var doc = document.documentElement; + support = !!(doc.requestFullscreen || doc.mozRequestFullScreen || doc.webkitRequestFullScreen || doc.msRequestFullscreen); + + if (document.fullscreenEnabled === false || document.mozFullScreenEnabled === false || document.webkitFullscreenEnabled === false || document.msFullscreenEnabled === false) { + support = false; + } + + return (0, _pdfjsLib.shadow)(this, 'supportsFullscreen', support); + }, + + get supportsIntegratedFind() { + return this.externalServices.supportsIntegratedFind; + }, + + get supportsDocumentFonts() { + return this.externalServices.supportsDocumentFonts; + }, + + get supportsDocumentColors() { + return this.externalServices.supportsDocumentColors; + }, + + get loadingBar() { + var bar = new _ui_utils.ProgressBar('#loadingBar'); + return (0, _pdfjsLib.shadow)(this, 'loadingBar', bar); + }, + + get supportedMouseWheelZoomModifierKeys() { + return this.externalServices.supportedMouseWheelZoomModifierKeys; + }, + + initPassiveLoading: function initPassiveLoading() { + throw new Error('Not implemented: initPassiveLoading'); + }, + setTitleUsingUrl: function setTitleUsingUrl() { + var url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; + this.url = url; + this.baseUrl = url.split('#')[0]; + var title = (0, _ui_utils.getPDFFileNameFromURL)(url, ''); + + if (!title) { + try { + title = decodeURIComponent((0, _pdfjsLib.getFilenameFromUrl)(url)) || url; + } catch (ex) { + title = url; + } + } + + this.setTitle(title); + }, + setTitle: function setTitle(title) { + if (this.isViewerEmbedded) { + return; + } + + document.title = title; + }, + close: function () { + var _close = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee6() { + var errorWrapper, promise; + return _regenerator.default.wrap(function _callee6$(_context6) { + while (1) { + switch (_context6.prev = _context6.next) { + case 0: + errorWrapper = this.appConfig.errorWrapper.container; + errorWrapper.setAttribute('hidden', 'true'); + + if (this.pdfLoadingTask) { + _context6.next = 4; + break; + } + + return _context6.abrupt("return"); + + case 4: + promise = this.pdfLoadingTask.destroy(); + this.pdfLoadingTask = null; + + if (this.pdfDocument) { + this.pdfDocument = null; + this.pdfThumbnailViewer.setDocument(null); + this.pdfViewer.setDocument(null); + this.pdfLinkService.setDocument(null); + this.pdfDocumentProperties.setDocument(null); + } + + this.store = null; + this.isInitialViewSet = false; + this.downloadComplete = false; + this.url = ''; + this.baseUrl = ''; + this.contentDispositionFilename = null; + this.pdfSidebar.reset(); + this.pdfOutlineViewer.reset(); + this.pdfAttachmentViewer.reset(); + this.findBar.reset(); + this.toolbar.reset(); + this.secondaryToolbar.reset(); + + if (typeof PDFBug !== 'undefined') { + PDFBug.cleanup(); + } + + return _context6.abrupt("return", promise); + + case 21: + case "end": + return _context6.stop(); + } + } + }, _callee6, this); + })); + + function close() { + return _close.apply(this, arguments); + } + + return close; + }(), + open: function () { + var _open = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee7(file, args) { + var _this2 = this; + + var workerParameters, key, parameters, apiParameters, _key, prop, loadingTask; + + return _regenerator.default.wrap(function _callee7$(_context7) { + while (1) { + switch (_context7.prev = _context7.next) { + case 0: + if (!this.pdfLoadingTask) { + _context7.next = 3; + break; + } + + _context7.next = 3; + return this.close(); + + case 3: + workerParameters = _app_options.AppOptions.getAll('worker'); + + for (key in workerParameters) { + _pdfjsLib.GlobalWorkerOptions[key] = workerParameters[key]; + } + + parameters = Object.create(null); + + if (typeof file === 'string') { + this.setTitleUsingUrl(file); + parameters.url = file; + } else if (file && 'byteLength' in file) { + parameters.data = file; + } else if (file.url && file.originalUrl) { + this.setTitleUsingUrl(file.originalUrl); + parameters.url = file.url; + } + + apiParameters = _app_options.AppOptions.getAll('api'); + + for (_key in apiParameters) { + parameters[_key] = apiParameters[_key]; + } + + if (args) { + for (prop in args) { + if (prop === 'length') { + this.pdfDocumentProperties.setFileSize(args[prop]); + } + + parameters[prop] = args[prop]; + } + } + + loadingTask = (0, _pdfjsLib.getDocument)(parameters); + this.pdfLoadingTask = loadingTask; + + loadingTask.onPassword = function (updateCallback, reason) { + _this2.passwordPrompt.setUpdateCallback(updateCallback, reason); + + _this2.passwordPrompt.open(); + }; + + loadingTask.onProgress = function (_ref) { + var loaded = _ref.loaded, + total = _ref.total; + + _this2.progress(loaded / total); + }; + + loadingTask.onUnsupportedFeature = this.fallback.bind(this); + return _context7.abrupt("return", loadingTask.promise.then(function (pdfDocument) { + _this2.load(pdfDocument); + }, function (exception) { + if (loadingTask !== _this2.pdfLoadingTask) { + return; + } + + var message = exception && exception.message; + var loadingErrorMessage; + + if (exception instanceof _pdfjsLib.InvalidPDFException) { + loadingErrorMessage = _this2.l10n.get('invalid_file_error', null, 'Invalid or corrupted PDF file.'); + } else if (exception instanceof _pdfjsLib.MissingPDFException) { + loadingErrorMessage = _this2.l10n.get('missing_file_error', null, 'Missing PDF file.'); + } else if (exception instanceof _pdfjsLib.UnexpectedResponseException) { + loadingErrorMessage = _this2.l10n.get('unexpected_response_error', null, 'Unexpected server response.'); + } else { + loadingErrorMessage = _this2.l10n.get('loading_error', null, 'An error occurred while loading the PDF.'); + } + + return loadingErrorMessage.then(function (msg) { + _this2.error(msg, { + message: message + }); + + throw new Error(msg); + }); + })); + + case 16: + case "end": + return _context7.stop(); + } + } + }, _callee7, this); + })); + + function open(_x2, _x3) { + return _open.apply(this, arguments); + } + + return open; + }(), + download: function download() { + var _this3 = this; + + function downloadByUrl() { + downloadManager.downloadUrl(url, filename); + } + + var url = this.baseUrl; + var filename = this.contentDispositionFilename || (0, _ui_utils.getPDFFileNameFromURL)(this.url); + var downloadManager = this.downloadManager; + + downloadManager.onerror = function (err) { + _this3.error("PDF failed to download: ".concat(err)); + }; + + if (!this.pdfDocument || !this.downloadComplete) { + downloadByUrl(); + return; + } + + this.pdfDocument.getData().then(function (data) { + var blob = new Blob([data], { + type: 'application/pdf' + }); + downloadManager.download(blob, url, filename); + }).catch(downloadByUrl); + }, + fallback: function fallback(featureId) {}, + error: function error(message, moreInfo) { + var moreInfoText = [this.l10n.get('error_version_info', { + version: _pdfjsLib.version || '?', + build: _pdfjsLib.build || '?' + }, 'PDF.js v{{version}} (build: {{build}})')]; + + if (moreInfo) { + moreInfoText.push(this.l10n.get('error_message', { + message: moreInfo.message + }, 'Message: {{message}}')); + + if (moreInfo.stack) { + moreInfoText.push(this.l10n.get('error_stack', { + stack: moreInfo.stack + }, 'Stack: {{stack}}')); + } else { + if (moreInfo.filename) { + moreInfoText.push(this.l10n.get('error_file', { + file: moreInfo.filename + }, 'File: {{file}}')); + } + + if (moreInfo.lineNumber) { + moreInfoText.push(this.l10n.get('error_line', { + line: moreInfo.lineNumber + }, 'Line: {{line}}')); + } + } + } + + var errorWrapperConfig = this.appConfig.errorWrapper; + var errorWrapper = errorWrapperConfig.container; + errorWrapper.removeAttribute('hidden'); + var errorMessage = errorWrapperConfig.errorMessage; + errorMessage.textContent = message; + var closeButton = errorWrapperConfig.closeButton; + + closeButton.onclick = function () { + errorWrapper.setAttribute('hidden', 'true'); + }; + + var errorMoreInfo = errorWrapperConfig.errorMoreInfo; + var moreInfoButton = errorWrapperConfig.moreInfoButton; + var lessInfoButton = errorWrapperConfig.lessInfoButton; + + moreInfoButton.onclick = function () { + errorMoreInfo.removeAttribute('hidden'); + moreInfoButton.setAttribute('hidden', 'true'); + lessInfoButton.removeAttribute('hidden'); + errorMoreInfo.style.height = errorMoreInfo.scrollHeight + 'px'; + }; + + lessInfoButton.onclick = function () { + errorMoreInfo.setAttribute('hidden', 'true'); + moreInfoButton.removeAttribute('hidden'); + lessInfoButton.setAttribute('hidden', 'true'); + }; + + moreInfoButton.oncontextmenu = _ui_utils.noContextMenuHandler; + lessInfoButton.oncontextmenu = _ui_utils.noContextMenuHandler; + closeButton.oncontextmenu = _ui_utils.noContextMenuHandler; + moreInfoButton.removeAttribute('hidden'); + lessInfoButton.setAttribute('hidden', 'true'); + Promise.all(moreInfoText).then(function (parts) { + errorMoreInfo.value = parts.join('\n'); + }); + }, + progress: function progress(level) { + var _this4 = this; + + if (this.downloadComplete) { + return; + } + + var percent = Math.round(level * 100); + + if (percent > this.loadingBar.percent || isNaN(percent)) { + this.loadingBar.percent = percent; + var disableAutoFetch = this.pdfDocument ? this.pdfDocument.loadingParams['disableAutoFetch'] : _app_options.AppOptions.get('disableAutoFetch'); + + if (disableAutoFetch && percent) { + if (this.disableAutoFetchLoadingBarTimeout) { + clearTimeout(this.disableAutoFetchLoadingBarTimeout); + this.disableAutoFetchLoadingBarTimeout = null; + } + + this.loadingBar.show(); + this.disableAutoFetchLoadingBarTimeout = setTimeout(function () { + _this4.loadingBar.hide(); + + _this4.disableAutoFetchLoadingBarTimeout = null; + }, DISABLE_AUTO_FETCH_LOADING_BAR_TIMEOUT); + } + } + }, + load: function load(pdfDocument) { + var _this5 = this; + + this.pdfDocument = pdfDocument; + pdfDocument.getDownloadInfo().then(function () { + _this5.downloadComplete = true; + + _this5.loadingBar.hide(); + + firstPagePromise.then(function () { + _this5.eventBus.dispatch('documentloaded', { + source: _this5 + }); + }); + }); + var pageModePromise = pdfDocument.getPageMode().catch(function () {}); + var openActionDestPromise = pdfDocument.getOpenActionDestination().catch(function () {}); + this.toolbar.setPagesCount(pdfDocument.numPages, false); + this.secondaryToolbar.setPagesCount(pdfDocument.numPages); + var store = this.store = new _view_history.ViewHistory(pdfDocument.fingerprint); + var baseDocumentUrl; + baseDocumentUrl = null; + this.pdfLinkService.setDocument(pdfDocument, baseDocumentUrl); + this.pdfDocumentProperties.setDocument(pdfDocument, this.url); + var pdfViewer = this.pdfViewer; + pdfViewer.setDocument(pdfDocument); + var firstPagePromise = pdfViewer.firstPagePromise; + var pagesPromise = pdfViewer.pagesPromise; + var onePageRendered = pdfViewer.onePageRendered; + var pdfThumbnailViewer = this.pdfThumbnailViewer; + pdfThumbnailViewer.setDocument(pdfDocument); + firstPagePromise.then(function (pdfPage) { + _this5.loadingBar.setWidth(_this5.appConfig.viewerContainer); + + var storePromise = store.getMultiple({ + page: null, + zoom: _ui_utils.DEFAULT_SCALE_VALUE, + scrollLeft: '0', + scrollTop: '0', + rotation: null, + sidebarView: _pdf_sidebar.SidebarView.UNKNOWN, + scrollMode: _ui_utils.ScrollMode.UNKNOWN, + spreadMode: _ui_utils.SpreadMode.UNKNOWN + }).catch(function () {}); + Promise.all([storePromise, pageModePromise, openActionDestPromise]).then( + /*#__PURE__*/ + function () { + var _ref3 = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee8(_ref2) { + var _ref4, _ref4$, values, pageMode, openActionDest, viewOnLoad, initialBookmark, zoom, hash, rotation, sidebarView, scrollMode, spreadMode; + + return _regenerator.default.wrap(function _callee8$(_context8) { + while (1) { + switch (_context8.prev = _context8.next) { + case 0: + _ref4 = _slicedToArray(_ref2, 3), _ref4$ = _ref4[0], values = _ref4$ === void 0 ? {} : _ref4$, pageMode = _ref4[1], openActionDest = _ref4[2]; + viewOnLoad = _app_options.AppOptions.get('viewOnLoad'); + + _this5._initializePdfHistory({ + fingerprint: pdfDocument.fingerprint, + viewOnLoad: viewOnLoad, + initialDest: openActionDest + }); + + initialBookmark = _this5.initialBookmark; + zoom = _app_options.AppOptions.get('defaultZoomValue'); + hash = zoom ? "zoom=".concat(zoom) : null; + rotation = null; + sidebarView = _app_options.AppOptions.get('sidebarViewOnLoad'); + scrollMode = _app_options.AppOptions.get('scrollModeOnLoad'); + spreadMode = _app_options.AppOptions.get('spreadModeOnLoad'); + + if (values.page && viewOnLoad !== ViewOnLoad.INITIAL) { + hash = "page=".concat(values.page, "&zoom=").concat(zoom || values.zoom, ",") + "".concat(values.scrollLeft, ",").concat(values.scrollTop); + rotation = parseInt(values.rotation, 10); + + if (sidebarView === _pdf_sidebar.SidebarView.UNKNOWN) { + sidebarView = values.sidebarView | 0; + } + + if (scrollMode === _ui_utils.ScrollMode.UNKNOWN) { + scrollMode = values.scrollMode | 0; + } + + if (spreadMode === _ui_utils.SpreadMode.UNKNOWN) { + spreadMode = values.spreadMode | 0; + } + } + + if (pageMode && sidebarView === _pdf_sidebar.SidebarView.UNKNOWN) { + sidebarView = apiPageModeToSidebarView(pageMode); + } + + _this5.setInitialView(hash, { + rotation: rotation, + sidebarView: sidebarView, + scrollMode: scrollMode, + spreadMode: spreadMode + }); + + _this5.eventBus.dispatch('documentinit', { + source: _this5 + }); + + if (!_this5.isViewerEmbedded) { + pdfViewer.focus(); + } + + _context8.next = 17; + return Promise.race([pagesPromise, new Promise(function (resolve) { + setTimeout(resolve, FORCE_PAGES_LOADED_TIMEOUT); + })]); + + case 17: + if (!(!initialBookmark && !hash)) { + _context8.next = 19; + break; + } + + return _context8.abrupt("return"); + + case 19: + if (!pdfViewer.hasEqualPageSizes) { + _context8.next = 21; + break; + } + + return _context8.abrupt("return"); + + case 21: + _this5.initialBookmark = initialBookmark; + pdfViewer.currentScaleValue = pdfViewer.currentScaleValue; + + _this5.setInitialView(hash); + + case 24: + case "end": + return _context8.stop(); + } + } + }, _callee8, this); + })); + + return function (_x4) { + return _ref3.apply(this, arguments); + }; + }()).catch(function () { + _this5.setInitialView(); + }).then(function () { + pdfViewer.update(); + }); + }); + pdfDocument.getPageLabels().then(function (labels) { + if (!labels || _app_options.AppOptions.get('disablePageLabels')) { + return; + } + + var i = 0, + numLabels = labels.length; + + if (numLabels !== _this5.pagesCount) { + console.error('The number of Page Labels does not match ' + 'the number of pages in the document.'); + return; + } + + while (i < numLabels && labels[i] === (i + 1).toString()) { + i++; + } + + if (i === numLabels) { + return; + } + + pdfViewer.setPageLabels(labels); + pdfThumbnailViewer.setPageLabels(labels); + + _this5.toolbar.setPagesCount(pdfDocument.numPages, true); + + _this5.toolbar.setPageNumber(pdfViewer.currentPageNumber, pdfViewer.currentPageLabel); + }); + pagesPromise.then(function () { + if (!_this5.supportsPrinting) { + return; + } + + pdfDocument.getJavaScript().then(function (javaScript) { + if (!javaScript) { + return; + } + + javaScript.some(function (js) { + if (!js) { + return false; + } + + console.warn('Warning: JavaScript is not supported'); + + _this5.fallback(_pdfjsLib.UNSUPPORTED_FEATURES.javaScript); + + return true; + }); + var regex = /\bprint\s*\(/; + + for (var i = 0, ii = javaScript.length; i < ii; i++) { + var js = javaScript[i]; + + if (js && regex.test(js)) { + setTimeout(function () { + window.print(); + }); + return; + } + } + }); + }); + Promise.all([onePageRendered, _ui_utils.animationStarted]).then(function () { + pdfDocument.getOutline().then(function (outline) { + _this5.pdfOutlineViewer.render({ + outline: outline + }); + }); + pdfDocument.getAttachments().then(function (attachments) { + _this5.pdfAttachmentViewer.render({ + attachments: attachments + }); + }); + }); + pdfDocument.getMetadata().then(function (_ref5) { + var info = _ref5.info, + metadata = _ref5.metadata, + contentDispositionFilename = _ref5.contentDispositionFilename; + _this5.documentInfo = info; + _this5.metadata = metadata; + _this5.contentDispositionFilename = contentDispositionFilename; + //console.log('PDF ' + pdfDocument.fingerprint + ' [' + info.PDFFormatVersion + ' ' + (info.Producer || '-').trim() + ' / ' + (info.Creator || '-').trim() + ']' + ' (PDF.js: ' + (_pdfjsLib.version || '-') + (_app_options.AppOptions.get('enableWebGL') ? ' [WebGL]' : '') + ')'); + var pdfTitle; + + if (metadata && metadata.has('dc:title')) { + var title = metadata.get('dc:title'); + + if (title !== 'Untitled') { + pdfTitle = title; + } + } + + if (!pdfTitle && info && info['Title']) { + pdfTitle = info['Title']; + } + + if (pdfTitle) { + _this5.setTitle("".concat(pdfTitle, " - ").concat(contentDispositionFilename || document.title)); + } else if (contentDispositionFilename) { + _this5.setTitle(contentDispositionFilename); + } + + if (info.IsAcroFormPresent) { + console.warn('Warning: AcroForm/XFA is not supported'); + + _this5.fallback(_pdfjsLib.UNSUPPORTED_FEATURES.forms); + } + }); + }, + _initializePdfHistory: function _initializePdfHistory(_ref6) { + var fingerprint = _ref6.fingerprint, + viewOnLoad = _ref6.viewOnLoad, + _ref6$initialDest = _ref6.initialDest, + initialDest = _ref6$initialDest === void 0 ? null : _ref6$initialDest; + + if (_app_options.AppOptions.get('disableHistory') || this.isViewerEmbedded) { + return; + } + + this.pdfHistory.initialize({ + fingerprint: fingerprint, + resetHistory: viewOnLoad === ViewOnLoad.INITIAL, + updateUrl: _app_options.AppOptions.get('historyUpdateUrl') + }); + + if (this.pdfHistory.initialBookmark) { + this.initialBookmark = this.pdfHistory.initialBookmark; + this.initialRotation = this.pdfHistory.initialRotation; + } + + if (initialDest && !this.initialBookmark && viewOnLoad === ViewOnLoad.UNKNOWN) { + this.initialBookmark = JSON.stringify(initialDest); + this.pdfHistory.push({ + explicitDest: initialDest, + pageNumber: null + }); + } + }, + setInitialView: function setInitialView(storedHash) { + var _this6 = this; + + var _ref7 = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, + rotation = _ref7.rotation, + sidebarView = _ref7.sidebarView, + scrollMode = _ref7.scrollMode, + spreadMode = _ref7.spreadMode; + + var setRotation = function setRotation(angle) { + if ((0, _ui_utils.isValidRotation)(angle)) { + _this6.pdfViewer.pagesRotation = angle; + } + }; + + var setViewerModes = function setViewerModes(scroll, spread) { + if ((0, _ui_utils.isValidScrollMode)(scroll)) { + _this6.pdfViewer.scrollMode = scroll; + } + + if ((0, _ui_utils.isValidSpreadMode)(spread)) { + _this6.pdfViewer.spreadMode = spread; + } + }; + + this.isInitialViewSet = true; + this.pdfSidebar.setInitialView(sidebarView); + setViewerModes(scrollMode, spreadMode); + + if (this.initialBookmark) { + setRotation(this.initialRotation); + delete this.initialRotation; + this.pdfLinkService.setHash(this.initialBookmark); + this.initialBookmark = null; + } else if (storedHash) { + setRotation(rotation); + this.pdfLinkService.setHash(storedHash); + } + + this.toolbar.setPageNumber(this.pdfViewer.currentPageNumber, this.pdfViewer.currentPageLabel); + this.secondaryToolbar.setPageNumber(this.pdfViewer.currentPageNumber); + + if (!this.pdfViewer.currentScaleValue) { + this.pdfViewer.currentScaleValue = _ui_utils.DEFAULT_SCALE_VALUE; + } + }, + cleanup: function cleanup() { + if (!this.pdfDocument) { + return; + } + + this.pdfViewer.cleanup(); + this.pdfThumbnailViewer.cleanup(); + + if (this.pdfViewer.renderer !== _ui_utils.RendererType.SVG) { + this.pdfDocument.cleanup(); + } + }, + forceRendering: function forceRendering() { + this.pdfRenderingQueue.printing = this.printing; + this.pdfRenderingQueue.isThumbnailViewEnabled = this.pdfSidebar.isThumbnailViewVisible; + this.pdfRenderingQueue.renderHighestPriority(); + }, + beforePrint: function beforePrint() { + var _this7 = this; + + if (this.printService) { + return; + } + + if (!this.supportsPrinting) { + this.l10n.get('printing_not_supported', null, 'Warning: Printing is not fully supported by ' + 'this browser.').then(function (printMessage) { + _this7.error(printMessage); + }); + return; + } + + if (!this.pdfViewer.pageViewsReady) { + this.l10n.get('printing_not_ready', null, 'Warning: The PDF is not fully loaded for printing.').then(function (notReadyMessage) { + window.alert(notReadyMessage); + }); + return; + } + + var pagesOverview = this.pdfViewer.getPagesOverview(); + var printContainer = this.appConfig.printContainer; + var printService = PDFPrintServiceFactory.instance.createPrintService(this.pdfDocument, pagesOverview, printContainer, this.l10n); + this.printService = printService; + this.forceRendering(); + printService.layout(); + }, + afterPrint: function pdfViewSetupAfterPrint() { + if (this.printService) { + this.printService.destroy(); + this.printService = null; + } + + this.forceRendering(); + }, + rotatePages: function rotatePages(delta) { + if (!this.pdfDocument) { + return; + } + + var newRotation = (this.pdfViewer.pagesRotation + 360 + delta) % 360; + this.pdfViewer.pagesRotation = newRotation; + }, + requestPresentationMode: function requestPresentationMode() { + if (!this.pdfPresentationMode) { + return; + } + + this.pdfPresentationMode.request(); + }, + bindEvents: function bindEvents() { + var eventBus = this.eventBus, + _boundEvents = this._boundEvents; + _boundEvents.beforePrint = this.beforePrint.bind(this); + _boundEvents.afterPrint = this.afterPrint.bind(this); + eventBus.on('resize', webViewerResize); + eventBus.on('hashchange', webViewerHashchange); + eventBus.on('beforeprint', _boundEvents.beforePrint); + eventBus.on('afterprint', _boundEvents.afterPrint); + eventBus.on('pagerendered', webViewerPageRendered); + eventBus.on('textlayerrendered', webViewerTextLayerRendered); + eventBus.on('updateviewarea', webViewerUpdateViewarea); + eventBus.on('pagechanging', webViewerPageChanging); + eventBus.on('scalechanging', webViewerScaleChanging); + eventBus.on('rotationchanging', webViewerRotationChanging); + eventBus.on('sidebarviewchanged', webViewerSidebarViewChanged); + eventBus.on('pagemode', webViewerPageMode); + eventBus.on('namedaction', webViewerNamedAction); + eventBus.on('presentationmodechanged', webViewerPresentationModeChanged); + eventBus.on('presentationmode', webViewerPresentationMode); + eventBus.on('openfile', webViewerOpenFile); + eventBus.on('print', webViewerPrint); + eventBus.on('download', webViewerDownload); + eventBus.on('firstpage', webViewerFirstPage); + eventBus.on('lastpage', webViewerLastPage); + eventBus.on('nextpage', webViewerNextPage); + eventBus.on('previouspage', webViewerPreviousPage); + eventBus.on('zoomin', webViewerZoomIn); + eventBus.on('zoomout', webViewerZoomOut); + eventBus.on('pagenumberchanged', webViewerPageNumberChanged); + eventBus.on('scalechanged', webViewerScaleChanged); + eventBus.on('rotatecw', webViewerRotateCw); + eventBus.on('rotateccw', webViewerRotateCcw); + eventBus.on('switchscrollmode', webViewerSwitchScrollMode); + eventBus.on('scrollmodechanged', webViewerScrollModeChanged); + eventBus.on('switchspreadmode', webViewerSwitchSpreadMode); + eventBus.on('spreadmodechanged', webViewerSpreadModeChanged); + eventBus.on('documentproperties', webViewerDocumentProperties); + eventBus.on('find', webViewerFind); + eventBus.on('findfromurlhash', webViewerFindFromUrlHash); + eventBus.on('updatefindmatchescount', webViewerUpdateFindMatchesCount); + eventBus.on('updatefindcontrolstate', webViewerUpdateFindControlState); + eventBus.on('fileinputchange', webViewerFileInputChange); + }, + bindWindowEvents: function bindWindowEvents() { + var eventBus = this.eventBus, + _boundEvents = this._boundEvents; + + _boundEvents.windowResize = function () { + eventBus.dispatch('resize', { + source: window + }); + }; + + _boundEvents.windowHashChange = function () { + eventBus.dispatch('hashchange', { + source: window, + hash: document.location.hash.substring(1) + }); + }; + + _boundEvents.windowBeforePrint = function () { + eventBus.dispatch('beforeprint', { + source: window + }); + }; + + _boundEvents.windowAfterPrint = function () { + eventBus.dispatch('afterprint', { + source: window + }); + }; + + window.addEventListener('visibilitychange', webViewerVisibilityChange); + window.addEventListener('wheel', webViewerWheel); + window.addEventListener('click', webViewerClick); + window.addEventListener('keydown', webViewerKeyDown); + window.addEventListener('resize', _boundEvents.windowResize); + window.addEventListener('hashchange', _boundEvents.windowHashChange); + window.addEventListener('beforeprint', _boundEvents.windowBeforePrint); + window.addEventListener('afterprint', _boundEvents.windowAfterPrint); + }, + unbindEvents: function unbindEvents() { + var eventBus = this.eventBus, + _boundEvents = this._boundEvents; + eventBus.off('resize', webViewerResize); + eventBus.off('hashchange', webViewerHashchange); + eventBus.off('beforeprint', _boundEvents.beforePrint); + eventBus.off('afterprint', _boundEvents.afterPrint); + eventBus.off('pagerendered', webViewerPageRendered); + eventBus.off('textlayerrendered', webViewerTextLayerRendered); + eventBus.off('updateviewarea', webViewerUpdateViewarea); + eventBus.off('pagechanging', webViewerPageChanging); + eventBus.off('scalechanging', webViewerScaleChanging); + eventBus.off('rotationchanging', webViewerRotationChanging); + eventBus.off('sidebarviewchanged', webViewerSidebarViewChanged); + eventBus.off('pagemode', webViewerPageMode); + eventBus.off('namedaction', webViewerNamedAction); + eventBus.off('presentationmodechanged', webViewerPresentationModeChanged); + eventBus.off('presentationmode', webViewerPresentationMode); + eventBus.off('openfile', webViewerOpenFile); + eventBus.off('print', webViewerPrint); + eventBus.off('download', webViewerDownload); + eventBus.off('firstpage', webViewerFirstPage); + eventBus.off('lastpage', webViewerLastPage); + eventBus.off('nextpage', webViewerNextPage); + eventBus.off('previouspage', webViewerPreviousPage); + eventBus.off('zoomin', webViewerZoomIn); + eventBus.off('zoomout', webViewerZoomOut); + eventBus.off('pagenumberchanged', webViewerPageNumberChanged); + eventBus.off('scalechanged', webViewerScaleChanged); + eventBus.off('rotatecw', webViewerRotateCw); + eventBus.off('rotateccw', webViewerRotateCcw); + eventBus.off('switchscrollmode', webViewerSwitchScrollMode); + eventBus.off('scrollmodechanged', webViewerScrollModeChanged); + eventBus.off('switchspreadmode', webViewerSwitchSpreadMode); + eventBus.off('spreadmodechanged', webViewerSpreadModeChanged); + eventBus.off('documentproperties', webViewerDocumentProperties); + eventBus.off('find', webViewerFind); + eventBus.off('findfromurlhash', webViewerFindFromUrlHash); + eventBus.off('updatefindmatchescount', webViewerUpdateFindMatchesCount); + eventBus.off('updatefindcontrolstate', webViewerUpdateFindControlState); + eventBus.off('fileinputchange', webViewerFileInputChange); + _boundEvents.beforePrint = null; + _boundEvents.afterPrint = null; + }, + unbindWindowEvents: function unbindWindowEvents() { + var _boundEvents = this._boundEvents; + window.removeEventListener('visibilitychange', webViewerVisibilityChange); + window.removeEventListener('wheel', webViewerWheel); + window.removeEventListener('click', webViewerClick); + window.removeEventListener('keydown', webViewerKeyDown); + window.removeEventListener('resize', _boundEvents.windowResize); + window.removeEventListener('hashchange', _boundEvents.windowHashChange); + window.removeEventListener('beforeprint', _boundEvents.windowBeforePrint); + window.removeEventListener('afterprint', _boundEvents.windowAfterPrint); + _boundEvents.windowResize = null; + _boundEvents.windowHashChange = null; + _boundEvents.windowBeforePrint = null; + _boundEvents.windowAfterPrint = null; + } +}; +exports.PDFViewerApplication = PDFViewerApplication; +var validateFileURL; +{ + var HOSTED_VIEWER_ORIGINS = ['null', 'http://mozilla.github.io', 'https://mozilla.github.io']; + + validateFileURL = function validateFileURL(file) { + if (file === undefined) { + return; + } + + try { + var viewerOrigin = new _pdfjsLib.URL(window.location.href).origin || 'null'; + + if (HOSTED_VIEWER_ORIGINS.includes(viewerOrigin)) { + return; + } + + var _ref8 = new _pdfjsLib.URL(file, window.location.href), + origin = _ref8.origin, + protocol = _ref8.protocol; + + if (origin !== viewerOrigin && protocol !== 'blob:') { + throw new Error('file origin does not match viewer\'s'); + } + } catch (ex) { + var message = ex && ex.message; + PDFViewerApplication.l10n.get('loading_error', null, 'An error occurred while loading the PDF.').then(function (loadingErrorMessage) { + PDFViewerApplication.error(loadingErrorMessage, { + message: message + }); + }); + throw ex; + } + }; +} + +function loadFakeWorker() { + if (!_pdfjsLib.GlobalWorkerOptions.workerSrc) { + _pdfjsLib.GlobalWorkerOptions.workerSrc = _app_options.AppOptions.get('workerSrc'); + } + + return (0, _pdfjsLib.loadScript)(_pdfjsLib.PDFWorker.getWorkerSrc()); +} + +function loadAndEnablePDFBug(enabledTabs) { + var appConfig = PDFViewerApplication.appConfig; + return (0, _pdfjsLib.loadScript)(appConfig.debuggerScriptPath).then(function () { + PDFBug.enable(enabledTabs); + PDFBug.init({ + OPS: _pdfjsLib.OPS, + createObjectURL: _pdfjsLib.createObjectURL + }, appConfig.mainContainer); + }); +} + +function webViewerInitialized() { + var appConfig = PDFViewerApplication.appConfig; + var file; + var queryString = document.location.search.substring(1); + var params = (0, _ui_utils.parseQueryString)(queryString); + file = 'file' in params ? params.file : _app_options.AppOptions.get('defaultUrl'); + validateFileURL(file); + var fileInput = document.createElement('input'); + fileInput.id = appConfig.openFileInputName; + fileInput.className = 'fileInput'; + fileInput.setAttribute('type', 'file'); + fileInput.oncontextmenu = _ui_utils.noContextMenuHandler; + document.body.appendChild(fileInput); + + if (!window.File || !window.FileReader || !window.FileList || !window.Blob) { + appConfig.toolbar.openFile.setAttribute('hidden', 'true'); + appConfig.secondaryToolbar.openFileButton.setAttribute('hidden', 'true'); + } else { + fileInput.value = null; + } + + fileInput.addEventListener('change', function (evt) { + var files = evt.target.files; + + if (!files || files.length === 0) { + return; + } + + PDFViewerApplication.eventBus.dispatch('fileinputchange', { + source: this, + fileInput: evt.target + }); + }); + appConfig.mainContainer.addEventListener('dragover', function (evt) { + evt.preventDefault(); + evt.dataTransfer.dropEffect = 'move'; + }); + appConfig.mainContainer.addEventListener('drop', function (evt) { + evt.preventDefault(); + var files = evt.dataTransfer.files; + + if (!files || files.length === 0) { + return; + } + + PDFViewerApplication.eventBus.dispatch('fileinputchange', { + source: this, + fileInput: evt.dataTransfer + }); + }); + + if (!PDFViewerApplication.supportsPrinting) { + appConfig.toolbar.print.classList.add('hidden'); + appConfig.secondaryToolbar.printButton.classList.add('hidden'); + } + + if (!PDFViewerApplication.supportsFullscreen) { + appConfig.toolbar.presentationModeButton.classList.add('hidden'); + appConfig.secondaryToolbar.presentationModeButton.classList.add('hidden'); + } + + if (PDFViewerApplication.supportsIntegratedFind) { + appConfig.toolbar.viewFind.classList.add('hidden'); + } + + appConfig.mainContainer.addEventListener('transitionend', function (evt) { + if (evt.target === this) { + PDFViewerApplication.eventBus.dispatch('resize', { + source: this + }); + } + }, true); + appConfig.sidebar.toggleButton.addEventListener('click', function () { + PDFViewerApplication.pdfSidebar.toggle(); + }); + + try { + webViewerOpenFileViaURL(file); + } catch (reason) { + PDFViewerApplication.l10n.get('loading_error', null, 'An error occurred while loading the PDF.').then(function (msg) { + PDFViewerApplication.error(msg, reason); + }); + } +} + +var webViewerOpenFileViaURL; +{ + webViewerOpenFileViaURL = function webViewerOpenFileViaURL(file) { + if (file && file.lastIndexOf('file:', 0) === 0) { + PDFViewerApplication.setTitleUsingUrl(file); + var xhr = new XMLHttpRequest(); + + xhr.onload = function () { + PDFViewerApplication.open(new Uint8Array(xhr.response)); + }; + + xhr.open('GET', file); + xhr.responseType = 'arraybuffer'; + xhr.send(); + return; + } + + if (file) { + PDFViewerApplication.open(file); + } + }; +} + +function webViewerPageRendered(evt) { + var pageNumber = evt.pageNumber; + var pageIndex = pageNumber - 1; + var pageView = PDFViewerApplication.pdfViewer.getPageView(pageIndex); + + if (pageNumber === PDFViewerApplication.page) { + PDFViewerApplication.toolbar.updateLoadingIndicatorState(false); + } + + if (!pageView) { + return; + } + + if (PDFViewerApplication.pdfSidebar.isThumbnailViewVisible) { + var thumbnailView = PDFViewerApplication.pdfThumbnailViewer.getThumbnail(pageIndex); + thumbnailView.setImage(pageView); + } + + if (typeof Stats !== 'undefined' && Stats.enabled && pageView.stats) { + Stats.add(pageNumber, pageView.stats); + } + + if (pageView.error) { + PDFViewerApplication.l10n.get('rendering_error', null, 'An error occurred while rendering the page.').then(function (msg) { + PDFViewerApplication.error(msg, pageView.error); + }); + } +} + +function webViewerTextLayerRendered(evt) {} + +function webViewerPageMode(evt) { + var mode = evt.mode, + view; + + switch (mode) { + case 'thumbs': + view = _pdf_sidebar.SidebarView.THUMBS; + break; + + case 'bookmarks': + case 'outline': + view = _pdf_sidebar.SidebarView.OUTLINE; + break; + + case 'attachments': + view = _pdf_sidebar.SidebarView.ATTACHMENTS; + break; + + case 'none': + view = _pdf_sidebar.SidebarView.NONE; + break; + + default: + console.error('Invalid "pagemode" hash parameter: ' + mode); + return; + } + + PDFViewerApplication.pdfSidebar.switchView(view, true); +} + +function webViewerNamedAction(evt) { + var action = evt.action; + + switch (action) { + case 'GoToPage': + PDFViewerApplication.appConfig.toolbar.pageNumber.select(); + break; + + case 'Find': + if (!PDFViewerApplication.supportsIntegratedFind) { + PDFViewerApplication.findBar.toggle(); + } + + break; + } +} + +function webViewerPresentationModeChanged(evt) { + var active = evt.active, + switchInProgress = evt.switchInProgress; + PDFViewerApplication.pdfViewer.presentationModeState = switchInProgress ? _ui_utils.PresentationModeState.CHANGING : active ? _ui_utils.PresentationModeState.FULLSCREEN : _ui_utils.PresentationModeState.NORMAL; +} + +function webViewerSidebarViewChanged(evt) { + PDFViewerApplication.pdfRenderingQueue.isThumbnailViewEnabled = PDFViewerApplication.pdfSidebar.isThumbnailViewVisible; + var store = PDFViewerApplication.store; + + if (store && PDFViewerApplication.isInitialViewSet) { + store.set('sidebarView', evt.view).catch(function () {}); + } +} + +function webViewerUpdateViewarea(evt) { + var location = evt.location, + store = PDFViewerApplication.store; + + if (store && PDFViewerApplication.isInitialViewSet) { + store.setMultiple({ + 'page': location.pageNumber, + 'zoom': location.scale, + 'scrollLeft': location.left, + 'scrollTop': location.top, + 'rotation': location.rotation + }).catch(function () {}); + } + + var href = PDFViewerApplication.pdfLinkService.getAnchorUrl(location.pdfOpenParams); + PDFViewerApplication.appConfig.toolbar.viewBookmark.href = href; + PDFViewerApplication.appConfig.secondaryToolbar.viewBookmarkButton.href = href; + var currentPage = PDFViewerApplication.pdfViewer.getPageView(PDFViewerApplication.page - 1); + var loading = currentPage.renderingState !== _pdf_rendering_queue.RenderingStates.FINISHED; + PDFViewerApplication.toolbar.updateLoadingIndicatorState(loading); +} + +function webViewerScrollModeChanged(evt) { + var store = PDFViewerApplication.store; + + if (store && PDFViewerApplication.isInitialViewSet) { + store.set('scrollMode', evt.mode).catch(function () {}); + } +} + +function webViewerSpreadModeChanged(evt) { + var store = PDFViewerApplication.store; + + if (store && PDFViewerApplication.isInitialViewSet) { + store.set('spreadMode', evt.mode).catch(function () {}); + } +} + +function webViewerResize() { + var pdfDocument = PDFViewerApplication.pdfDocument, + pdfViewer = PDFViewerApplication.pdfViewer; + + if (!pdfDocument) { + return; + } + + var currentScaleValue = pdfViewer.currentScaleValue; + + if (currentScaleValue === 'auto' || currentScaleValue === 'page-fit' || currentScaleValue === 'page-width') { + pdfViewer.currentScaleValue = currentScaleValue; + } + + pdfViewer.update(); +} + +function webViewerHashchange(evt) { + var hash = evt.hash; + + if (!hash) { + return; + } + + if (!PDFViewerApplication.isInitialViewSet) { + PDFViewerApplication.initialBookmark = hash; + } else if (!PDFViewerApplication.pdfHistory.popStateInProgress) { + PDFViewerApplication.pdfLinkService.setHash(hash); + } +} + +var webViewerFileInputChange; +{ + webViewerFileInputChange = function webViewerFileInputChange(evt) { + if (PDFViewerApplication.pdfViewer && PDFViewerApplication.pdfViewer.isInPresentationMode) { + return; + } + + var file = evt.fileInput.files[0]; + + if (_pdfjsLib.URL.createObjectURL && !_app_options.AppOptions.get('disableCreateObjectURL')) { + var url = _pdfjsLib.URL.createObjectURL(file); + + if (file.name) { + url = { + url: url, + originalUrl: file.name + }; + } + + PDFViewerApplication.open(url); + } else { + PDFViewerApplication.setTitleUsingUrl(file.name); + var fileReader = new FileReader(); + + fileReader.onload = function webViewerChangeFileReaderOnload(evt) { + var buffer = evt.target.result; + PDFViewerApplication.open(new Uint8Array(buffer)); + }; + + fileReader.readAsArrayBuffer(file); + } + + var appConfig = PDFViewerApplication.appConfig; + appConfig.toolbar.viewBookmark.setAttribute('hidden', 'true'); + appConfig.secondaryToolbar.viewBookmarkButton.setAttribute('hidden', 'true'); + appConfig.toolbar.download.setAttribute('hidden', 'true'); + appConfig.secondaryToolbar.downloadButton.setAttribute('hidden', 'true'); + }; +} + +function webViewerPresentationMode() { + PDFViewerApplication.requestPresentationMode(); +} + +function webViewerOpenFile() { + var openFileInputName = PDFViewerApplication.appConfig.openFileInputName; + document.getElementById(openFileInputName).click(); +} + +function webViewerPrint() { + window.print(); +} + +function webViewerDownload() { + PDFViewerApplication.download(); +} + +function webViewerFirstPage() { + if (PDFViewerApplication.pdfDocument) { + PDFViewerApplication.page = 1; + } +} + +function webViewerLastPage() { + if (PDFViewerApplication.pdfDocument) { + PDFViewerApplication.page = PDFViewerApplication.pagesCount; + } +} + +function webViewerNextPage() { + PDFViewerApplication.page++; +} + +function webViewerPreviousPage() { + PDFViewerApplication.page--; +} + +function webViewerZoomIn() { + PDFViewerApplication.zoomIn(); +} + +function webViewerZoomOut() { + PDFViewerApplication.zoomOut(); +} + +function webViewerPageNumberChanged(evt) { + var pdfViewer = PDFViewerApplication.pdfViewer; + + if (evt.value !== '') { + pdfViewer.currentPageLabel = evt.value; + } + + if (evt.value !== pdfViewer.currentPageNumber.toString() && evt.value !== pdfViewer.currentPageLabel) { + PDFViewerApplication.toolbar.setPageNumber(pdfViewer.currentPageNumber, pdfViewer.currentPageLabel); + } +} + +function webViewerScaleChanged(evt) { + PDFViewerApplication.pdfViewer.currentScaleValue = evt.value; +} + +function webViewerRotateCw() { + PDFViewerApplication.rotatePages(90); +} + +function webViewerRotateCcw() { + PDFViewerApplication.rotatePages(-90); +} + +function webViewerSwitchScrollMode(evt) { + PDFViewerApplication.pdfViewer.scrollMode = evt.mode; +} + +function webViewerSwitchSpreadMode(evt) { + PDFViewerApplication.pdfViewer.spreadMode = evt.mode; +} + +function webViewerDocumentProperties() { + PDFViewerApplication.pdfDocumentProperties.open(); +} + +function webViewerFind(evt) { + PDFViewerApplication.findController.executeCommand('find' + evt.type, { + query: evt.query, + phraseSearch: evt.phraseSearch, + caseSensitive: evt.caseSensitive, + entireWord: evt.entireWord, + highlightAll: evt.highlightAll, + findPrevious: evt.findPrevious + }); +} + +function webViewerFindFromUrlHash(evt) { + PDFViewerApplication.findController.executeCommand('find', { + query: evt.query, + phraseSearch: evt.phraseSearch, + caseSensitive: false, + entireWord: false, + highlightAll: true, + findPrevious: false + }); +} + +function webViewerUpdateFindMatchesCount(_ref9) { + var matchesCount = _ref9.matchesCount; + + if (PDFViewerApplication.supportsIntegratedFind) { + PDFViewerApplication.externalServices.updateFindMatchesCount(matchesCount); + } else { + PDFViewerApplication.findBar.updateResultsCount(matchesCount); + } +} + +function webViewerUpdateFindControlState(_ref10) { + var state = _ref10.state, + previous = _ref10.previous, + matchesCount = _ref10.matchesCount; + + if (PDFViewerApplication.supportsIntegratedFind) { + PDFViewerApplication.externalServices.updateFindControlState({ + result: state, + findPrevious: previous, + matchesCount: matchesCount + }); + } else { + PDFViewerApplication.findBar.updateUIState(state, previous, matchesCount); + } +} + +function webViewerScaleChanging(evt) { + PDFViewerApplication.toolbar.setPageScale(evt.presetValue, evt.scale); + PDFViewerApplication.pdfViewer.update(); +} + +function webViewerRotationChanging(evt) { + PDFViewerApplication.pdfThumbnailViewer.pagesRotation = evt.pagesRotation; + PDFViewerApplication.forceRendering(); + PDFViewerApplication.pdfViewer.currentPageNumber = evt.pageNumber; +} + +function webViewerPageChanging(evt) { + var page = evt.pageNumber; + PDFViewerApplication.toolbar.setPageNumber(page, evt.pageLabel || null); + PDFViewerApplication.secondaryToolbar.setPageNumber(page); + + if (PDFViewerApplication.pdfSidebar.isThumbnailViewVisible) { + PDFViewerApplication.pdfThumbnailViewer.scrollThumbnailIntoView(page); + } + + if (typeof Stats !== 'undefined' && Stats.enabled) { + var pageView = PDFViewerApplication.pdfViewer.getPageView(page - 1); + + if (pageView && pageView.stats) { + Stats.add(page, pageView.stats); + } + } +} + +function webViewerVisibilityChange(evt) { + if (document.visibilityState === 'visible') { + setZoomDisabledTimeout(); + } +} + +var zoomDisabledTimeout = null; + +function setZoomDisabledTimeout() { + if (zoomDisabledTimeout) { + clearTimeout(zoomDisabledTimeout); + } + + zoomDisabledTimeout = setTimeout(function () { + zoomDisabledTimeout = null; + }, WHEEL_ZOOM_DISABLED_TIMEOUT); +} + +function webViewerWheel(evt) { + var pdfViewer = PDFViewerApplication.pdfViewer; + + if (pdfViewer.isInPresentationMode) { + return; + } + + if (evt.ctrlKey || evt.metaKey) { + var support = PDFViewerApplication.supportedMouseWheelZoomModifierKeys; + + if (evt.ctrlKey && !support.ctrlKey || evt.metaKey && !support.metaKey) { + return; + } + + evt.preventDefault(); + + if (zoomDisabledTimeout || document.visibilityState === 'hidden') { + return; + } + + var previousScale = pdfViewer.currentScale; + var delta = (0, _ui_utils.normalizeWheelEventDelta)(evt); + var MOUSE_WHEEL_DELTA_PER_PAGE_SCALE = 3.0; + var ticks = delta * MOUSE_WHEEL_DELTA_PER_PAGE_SCALE; + + if (ticks < 0) { + PDFViewerApplication.zoomOut(-ticks); + } else { + PDFViewerApplication.zoomIn(ticks); + } + + var currentScale = pdfViewer.currentScale; + + if (previousScale !== currentScale) { + var scaleCorrectionFactor = currentScale / previousScale - 1; + var rect = pdfViewer.container.getBoundingClientRect(); + var dx = evt.clientX - rect.left; + var dy = evt.clientY - rect.top; + pdfViewer.container.scrollLeft += dx * scaleCorrectionFactor; + pdfViewer.container.scrollTop += dy * scaleCorrectionFactor; + } + } else { + setZoomDisabledTimeout(); + } +} + +function webViewerClick(evt) { + if (!PDFViewerApplication.secondaryToolbar.isOpen) { + return; + } + + var appConfig = PDFViewerApplication.appConfig; + + if (PDFViewerApplication.pdfViewer.containsElement(evt.target) || appConfig.toolbar.container.contains(evt.target) && evt.target !== appConfig.secondaryToolbar.toggleButton) { + PDFViewerApplication.secondaryToolbar.close(); + } +} + +function webViewerKeyDown(evt) { + if (PDFViewerApplication.overlayManager.active) { + return; + } + + var handled = false, + ensureViewerFocused = false; + var cmd = (evt.ctrlKey ? 1 : 0) | (evt.altKey ? 2 : 0) | (evt.shiftKey ? 4 : 0) | (evt.metaKey ? 8 : 0); + var pdfViewer = PDFViewerApplication.pdfViewer; + var isViewerInPresentationMode = pdfViewer && pdfViewer.isInPresentationMode; + + if (cmd === 1 || cmd === 8 || cmd === 5 || cmd === 12) { + switch (evt.keyCode) { + case 70: + if (!PDFViewerApplication.supportsIntegratedFind) { + PDFViewerApplication.findBar.open(); + handled = true; + } + + break; + + case 71: + if (!PDFViewerApplication.supportsIntegratedFind) { + var findState = PDFViewerApplication.findController.state; + + if (findState) { + PDFViewerApplication.findController.executeCommand('findagain', { + query: findState.query, + phraseSearch: findState.phraseSearch, + caseSensitive: findState.caseSensitive, + entireWord: findState.entireWord, + highlightAll: findState.highlightAll, + findPrevious: cmd === 5 || cmd === 12 + }); + } + + handled = true; + } + + break; + + case 61: + case 107: + case 187: + case 171: + if (!isViewerInPresentationMode) { + PDFViewerApplication.zoomIn(); + } + + handled = true; + break; + + case 173: + case 109: + case 189: + if (!isViewerInPresentationMode) { + PDFViewerApplication.zoomOut(); + } + + handled = true; + break; + + case 48: + case 96: + if (!isViewerInPresentationMode) { + setTimeout(function () { + pdfViewer.currentScaleValue = _ui_utils.DEFAULT_SCALE_VALUE; + }); + handled = false; + } + + break; + + case 38: + if (isViewerInPresentationMode || PDFViewerApplication.page > 1) { + PDFViewerApplication.page = 1; + handled = true; + ensureViewerFocused = true; + } + + break; + + case 40: + if (isViewerInPresentationMode || PDFViewerApplication.page < PDFViewerApplication.pagesCount) { + PDFViewerApplication.page = PDFViewerApplication.pagesCount; + handled = true; + ensureViewerFocused = true; + } + + break; + } + } + + if (cmd === 1 || cmd === 8) { + switch (evt.keyCode) { + case 83: + PDFViewerApplication.download(); + handled = true; + break; + } + } + + if (cmd === 3 || cmd === 10) { + switch (evt.keyCode) { + case 80: + PDFViewerApplication.requestPresentationMode(); + handled = true; + break; + + case 71: + PDFViewerApplication.appConfig.toolbar.pageNumber.select(); + handled = true; + break; + } + } + + if (handled) { + if (ensureViewerFocused && !isViewerInPresentationMode) { + pdfViewer.focus(); + } + + evt.preventDefault(); + return; + } + + var curElement = document.activeElement || document.querySelector(':focus'); + var curElementTagName = curElement && curElement.tagName.toUpperCase(); + + if (curElementTagName === 'INPUT' || curElementTagName === 'TEXTAREA' || curElementTagName === 'SELECT') { + if (evt.keyCode !== 27) { + return; + } + } + + if (cmd === 0) { + var turnPage = 0, + turnOnlyIfPageFit = false; + + switch (evt.keyCode) { + case 38: + case 33: + if (pdfViewer.isVerticalScrollbarEnabled) { + turnOnlyIfPageFit = true; + } + + turnPage = -1; + break; + + case 8: + if (!isViewerInPresentationMode) { + turnOnlyIfPageFit = true; + } + + turnPage = -1; + break; + + case 37: + if (pdfViewer.isHorizontalScrollbarEnabled) { + turnOnlyIfPageFit = true; + } + + case 75: + case 80: + turnPage = -1; + break; + + case 27: + if (PDFViewerApplication.secondaryToolbar.isOpen) { + PDFViewerApplication.secondaryToolbar.close(); + handled = true; + } + + if (!PDFViewerApplication.supportsIntegratedFind && PDFViewerApplication.findBar.opened) { + PDFViewerApplication.findBar.close(); + handled = true; + } + + break; + + case 40: + case 34: + if (pdfViewer.isVerticalScrollbarEnabled) { + turnOnlyIfPageFit = true; + } + + turnPage = 1; + break; + + case 13: + case 32: + if (!isViewerInPresentationMode) { + turnOnlyIfPageFit = true; + } + + turnPage = 1; + break; + + case 39: + if (pdfViewer.isHorizontalScrollbarEnabled) { + turnOnlyIfPageFit = true; + } + + case 74: + case 78: + turnPage = 1; + break; + + case 36: + if (isViewerInPresentationMode || PDFViewerApplication.page > 1) { + PDFViewerApplication.page = 1; + handled = true; + ensureViewerFocused = true; + } + + break; + + case 35: + if (isViewerInPresentationMode || PDFViewerApplication.page < PDFViewerApplication.pagesCount) { + PDFViewerApplication.page = PDFViewerApplication.pagesCount; + handled = true; + ensureViewerFocused = true; + } + + break; + + case 83: + PDFViewerApplication.pdfCursorTools.switchTool(_pdf_cursor_tools.CursorTool.SELECT); + break; + + case 72: + PDFViewerApplication.pdfCursorTools.switchTool(_pdf_cursor_tools.CursorTool.HAND); + break; + + case 82: + PDFViewerApplication.rotatePages(90); + break; + + case 115: + PDFViewerApplication.pdfSidebar.toggle(); + break; + } + + if (turnPage !== 0 && (!turnOnlyIfPageFit || pdfViewer.currentScaleValue === 'page-fit')) { + if (turnPage > 0) { + if (PDFViewerApplication.page < PDFViewerApplication.pagesCount) { + PDFViewerApplication.page++; + } + } else { + if (PDFViewerApplication.page > 1) { + PDFViewerApplication.page--; + } + } + + handled = true; + } + } + + if (cmd === 4) { + switch (evt.keyCode) { + case 13: + case 32: + if (!isViewerInPresentationMode && pdfViewer.currentScaleValue !== 'page-fit') { + break; + } + + if (PDFViewerApplication.page > 1) { + PDFViewerApplication.page--; + } + + handled = true; + break; + + case 82: + PDFViewerApplication.rotatePages(-90); + break; + } + } + + if (!handled && !isViewerInPresentationMode) { + if (evt.keyCode >= 33 && evt.keyCode <= 40 || evt.keyCode === 32 && curElementTagName !== 'BUTTON') { + ensureViewerFocused = true; + } + } + + if (ensureViewerFocused && !pdfViewer.containsElement(curElement)) { + pdfViewer.focus(); + } + + if (handled) { + evt.preventDefault(); + } +} + +function apiPageModeToSidebarView(mode) { + switch (mode) { + case 'UseNone': + return _pdf_sidebar.SidebarView.NONE; + + case 'UseThumbs': + return _pdf_sidebar.SidebarView.THUMBS; + + case 'UseOutlines': + return _pdf_sidebar.SidebarView.OUTLINE; + + case 'UseAttachments': + return _pdf_sidebar.SidebarView.ATTACHMENTS; + + case 'UseOC': + } + + return _pdf_sidebar.SidebarView.NONE; +} + +var PDFPrintServiceFactory = { + instance: { + supportsPrinting: false, + createPrintService: function createPrintService() { + throw new Error('Not implemented: createPrintService'); + } + } +}; +exports.PDFPrintServiceFactory = PDFPrintServiceFactory; + +/***/ }), +/* 2 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +module.exports = __webpack_require__(3); + +/***/ }), +/* 3 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +var g = function () { + return this || (typeof self === "undefined" ? "undefined" : _typeof(self)) === "object" && self; +}() || Function("return this")(); + +var hadRuntime = g.regeneratorRuntime && Object.getOwnPropertyNames(g).indexOf("regeneratorRuntime") >= 0; +var oldRuntime = hadRuntime && g.regeneratorRuntime; +g.regeneratorRuntime = undefined; +module.exports = __webpack_require__(4); + +if (hadRuntime) { + g.regeneratorRuntime = oldRuntime; +} else { + try { + delete g.regeneratorRuntime; + } catch (e) { + g.regeneratorRuntime = undefined; + } +} + +/***/ }), +/* 4 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; +/* WEBPACK VAR INJECTION */(function(module) { + +function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +!function (global) { + "use strict"; + + var Op = Object.prototype; + var hasOwn = Op.hasOwnProperty; + var undefined; + var $Symbol = typeof Symbol === "function" ? Symbol : {}; + var iteratorSymbol = $Symbol.iterator || "@@iterator"; + var asyncIteratorSymbol = $Symbol.asyncIterator || "@@asyncIterator"; + var toStringTagSymbol = $Symbol.toStringTag || "@@toStringTag"; + var inModule = ( false ? undefined : _typeof(module)) === "object"; + var runtime = global.regeneratorRuntime; + + if (runtime) { + if (inModule) { + module.exports = runtime; + } + + return; + } + + runtime = global.regeneratorRuntime = inModule ? module.exports : {}; + + function wrap(innerFn, outerFn, self, tryLocsList) { + var protoGenerator = outerFn && outerFn.prototype instanceof Generator ? outerFn : Generator; + var generator = Object.create(protoGenerator.prototype); + var context = new Context(tryLocsList || []); + generator._invoke = makeInvokeMethod(innerFn, self, context); + return generator; + } + + runtime.wrap = wrap; + + function tryCatch(fn, obj, arg) { + try { + return { + type: "normal", + arg: fn.call(obj, arg) + }; + } catch (err) { + return { + type: "throw", + arg: err + }; + } + } + + var GenStateSuspendedStart = "suspendedStart"; + var GenStateSuspendedYield = "suspendedYield"; + var GenStateExecuting = "executing"; + var GenStateCompleted = "completed"; + var ContinueSentinel = {}; + + function Generator() {} + + function GeneratorFunction() {} + + function GeneratorFunctionPrototype() {} + + var IteratorPrototype = {}; + + IteratorPrototype[iteratorSymbol] = function () { + return this; + }; + + var getProto = Object.getPrototypeOf; + var NativeIteratorPrototype = getProto && getProto(getProto(values([]))); + + if (NativeIteratorPrototype && NativeIteratorPrototype !== Op && hasOwn.call(NativeIteratorPrototype, iteratorSymbol)) { + IteratorPrototype = NativeIteratorPrototype; + } + + var Gp = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(IteratorPrototype); + GeneratorFunction.prototype = Gp.constructor = GeneratorFunctionPrototype; + GeneratorFunctionPrototype.constructor = GeneratorFunction; + GeneratorFunctionPrototype[toStringTagSymbol] = GeneratorFunction.displayName = "GeneratorFunction"; + + function defineIteratorMethods(prototype) { + ["next", "throw", "return"].forEach(function (method) { + prototype[method] = function (arg) { + return this._invoke(method, arg); + }; + }); + } + + runtime.isGeneratorFunction = function (genFun) { + var ctor = typeof genFun === "function" && genFun.constructor; + return ctor ? ctor === GeneratorFunction || (ctor.displayName || ctor.name) === "GeneratorFunction" : false; + }; + + runtime.mark = function (genFun) { + if (Object.setPrototypeOf) { + Object.setPrototypeOf(genFun, GeneratorFunctionPrototype); + } else { + genFun.__proto__ = GeneratorFunctionPrototype; + + if (!(toStringTagSymbol in genFun)) { + genFun[toStringTagSymbol] = "GeneratorFunction"; + } + } + + genFun.prototype = Object.create(Gp); + return genFun; + }; + + runtime.awrap = function (arg) { + return { + __await: arg + }; + }; + + function AsyncIterator(generator) { + function invoke(method, arg, resolve, reject) { + var record = tryCatch(generator[method], generator, arg); + + if (record.type === "throw") { + reject(record.arg); + } else { + var result = record.arg; + var value = result.value; + + if (value && _typeof(value) === "object" && hasOwn.call(value, "__await")) { + return Promise.resolve(value.__await).then(function (value) { + invoke("next", value, resolve, reject); + }, function (err) { + invoke("throw", err, resolve, reject); + }); + } + + return Promise.resolve(value).then(function (unwrapped) { + result.value = unwrapped; + resolve(result); + }, function (error) { + return invoke("throw", error, resolve, reject); + }); + } + } + + var previousPromise; + + function enqueue(method, arg) { + function callInvokeWithMethodAndArg() { + return new Promise(function (resolve, reject) { + invoke(method, arg, resolve, reject); + }); + } + + return previousPromise = previousPromise ? previousPromise.then(callInvokeWithMethodAndArg, callInvokeWithMethodAndArg) : callInvokeWithMethodAndArg(); + } + + this._invoke = enqueue; + } + + defineIteratorMethods(AsyncIterator.prototype); + + AsyncIterator.prototype[asyncIteratorSymbol] = function () { + return this; + }; + + runtime.AsyncIterator = AsyncIterator; + + runtime.async = function (innerFn, outerFn, self, tryLocsList) { + var iter = new AsyncIterator(wrap(innerFn, outerFn, self, tryLocsList)); + return runtime.isGeneratorFunction(outerFn) ? iter : iter.next().then(function (result) { + return result.done ? result.value : iter.next(); + }); + }; + + function makeInvokeMethod(innerFn, self, context) { + var state = GenStateSuspendedStart; + return function invoke(method, arg) { + if (state === GenStateExecuting) { + throw new Error("Generator is already running"); + } + + if (state === GenStateCompleted) { + if (method === "throw") { + throw arg; + } + + return doneResult(); + } + + context.method = method; + context.arg = arg; + + while (true) { + var delegate = context.delegate; + + if (delegate) { + var delegateResult = maybeInvokeDelegate(delegate, context); + + if (delegateResult) { + if (delegateResult === ContinueSentinel) continue; + return delegateResult; + } + } + + if (context.method === "next") { + context.sent = context._sent = context.arg; + } else if (context.method === "throw") { + if (state === GenStateSuspendedStart) { + state = GenStateCompleted; + throw context.arg; + } + + context.dispatchException(context.arg); + } else if (context.method === "return") { + context.abrupt("return", context.arg); + } + + state = GenStateExecuting; + var record = tryCatch(innerFn, self, context); + + if (record.type === "normal") { + state = context.done ? GenStateCompleted : GenStateSuspendedYield; + + if (record.arg === ContinueSentinel) { + continue; + } + + return { + value: record.arg, + done: context.done + }; + } else if (record.type === "throw") { + state = GenStateCompleted; + context.method = "throw"; + context.arg = record.arg; + } + } + }; + } + + function maybeInvokeDelegate(delegate, context) { + var method = delegate.iterator[context.method]; + + if (method === undefined) { + context.delegate = null; + + if (context.method === "throw") { + if (delegate.iterator.return) { + context.method = "return"; + context.arg = undefined; + maybeInvokeDelegate(delegate, context); + + if (context.method === "throw") { + return ContinueSentinel; + } + } + + context.method = "throw"; + context.arg = new TypeError("The iterator does not provide a 'throw' method"); + } + + return ContinueSentinel; + } + + var record = tryCatch(method, delegate.iterator, context.arg); + + if (record.type === "throw") { + context.method = "throw"; + context.arg = record.arg; + context.delegate = null; + return ContinueSentinel; + } + + var info = record.arg; + + if (!info) { + context.method = "throw"; + context.arg = new TypeError("iterator result is not an object"); + context.delegate = null; + return ContinueSentinel; + } + + if (info.done) { + context[delegate.resultName] = info.value; + context.next = delegate.nextLoc; + + if (context.method !== "return") { + context.method = "next"; + context.arg = undefined; + } + } else { + return info; + } + + context.delegate = null; + return ContinueSentinel; + } + + defineIteratorMethods(Gp); + Gp[toStringTagSymbol] = "Generator"; + + Gp[iteratorSymbol] = function () { + return this; + }; + + Gp.toString = function () { + return "[object Generator]"; + }; + + function pushTryEntry(locs) { + var entry = { + tryLoc: locs[0] + }; + + if (1 in locs) { + entry.catchLoc = locs[1]; + } + + if (2 in locs) { + entry.finallyLoc = locs[2]; + entry.afterLoc = locs[3]; + } + + this.tryEntries.push(entry); + } + + function resetTryEntry(entry) { + var record = entry.completion || {}; + record.type = "normal"; + delete record.arg; + entry.completion = record; + } + + function Context(tryLocsList) { + this.tryEntries = [{ + tryLoc: "root" + }]; + tryLocsList.forEach(pushTryEntry, this); + this.reset(true); + } + + runtime.keys = function (object) { + var keys = []; + + for (var key in object) { + keys.push(key); + } + + keys.reverse(); + return function next() { + while (keys.length) { + var key = keys.pop(); + + if (key in object) { + next.value = key; + next.done = false; + return next; + } + } + + next.done = true; + return next; + }; + }; + + function values(iterable) { + if (iterable) { + var iteratorMethod = iterable[iteratorSymbol]; + + if (iteratorMethod) { + return iteratorMethod.call(iterable); + } + + if (typeof iterable.next === "function") { + return iterable; + } + + if (!isNaN(iterable.length)) { + var i = -1, + next = function next() { + while (++i < iterable.length) { + if (hasOwn.call(iterable, i)) { + next.value = iterable[i]; + next.done = false; + return next; + } + } + + next.value = undefined; + next.done = true; + return next; + }; + + return next.next = next; + } + } + + return { + next: doneResult + }; + } + + runtime.values = values; + + function doneResult() { + return { + value: undefined, + done: true + }; + } + + Context.prototype = { + constructor: Context, + reset: function reset(skipTempReset) { + this.prev = 0; + this.next = 0; + this.sent = this._sent = undefined; + this.done = false; + this.delegate = null; + this.method = "next"; + this.arg = undefined; + this.tryEntries.forEach(resetTryEntry); + + if (!skipTempReset) { + for (var name in this) { + if (name.charAt(0) === "t" && hasOwn.call(this, name) && !isNaN(+name.slice(1))) { + this[name] = undefined; + } + } + } + }, + stop: function stop() { + this.done = true; + var rootEntry = this.tryEntries[0]; + var rootRecord = rootEntry.completion; + + if (rootRecord.type === "throw") { + throw rootRecord.arg; + } + + return this.rval; + }, + dispatchException: function dispatchException(exception) { + if (this.done) { + throw exception; + } + + var context = this; + + function handle(loc, caught) { + record.type = "throw"; + record.arg = exception; + context.next = loc; + + if (caught) { + context.method = "next"; + context.arg = undefined; + } + + return !!caught; + } + + for (var i = this.tryEntries.length - 1; i >= 0; --i) { + var entry = this.tryEntries[i]; + var record = entry.completion; + + if (entry.tryLoc === "root") { + return handle("end"); + } + + if (entry.tryLoc <= this.prev) { + var hasCatch = hasOwn.call(entry, "catchLoc"); + var hasFinally = hasOwn.call(entry, "finallyLoc"); + + if (hasCatch && hasFinally) { + if (this.prev < entry.catchLoc) { + return handle(entry.catchLoc, true); + } else if (this.prev < entry.finallyLoc) { + return handle(entry.finallyLoc); + } + } else if (hasCatch) { + if (this.prev < entry.catchLoc) { + return handle(entry.catchLoc, true); + } + } else if (hasFinally) { + if (this.prev < entry.finallyLoc) { + return handle(entry.finallyLoc); + } + } else { + throw new Error("try statement without catch or finally"); + } + } + } + }, + abrupt: function abrupt(type, arg) { + for (var i = this.tryEntries.length - 1; i >= 0; --i) { + var entry = this.tryEntries[i]; + + if (entry.tryLoc <= this.prev && hasOwn.call(entry, "finallyLoc") && this.prev < entry.finallyLoc) { + var finallyEntry = entry; + break; + } + } + + if (finallyEntry && (type === "break" || type === "continue") && finallyEntry.tryLoc <= arg && arg <= finallyEntry.finallyLoc) { + finallyEntry = null; + } + + var record = finallyEntry ? finallyEntry.completion : {}; + record.type = type; + record.arg = arg; + + if (finallyEntry) { + this.method = "next"; + this.next = finallyEntry.finallyLoc; + return ContinueSentinel; + } + + return this.complete(record); + }, + complete: function complete(record, afterLoc) { + if (record.type === "throw") { + throw record.arg; + } + + if (record.type === "break" || record.type === "continue") { + this.next = record.arg; + } else if (record.type === "return") { + this.rval = this.arg = record.arg; + this.method = "return"; + this.next = "end"; + } else if (record.type === "normal" && afterLoc) { + this.next = afterLoc; + } + + return ContinueSentinel; + }, + finish: function finish(finallyLoc) { + for (var i = this.tryEntries.length - 1; i >= 0; --i) { + var entry = this.tryEntries[i]; + + if (entry.finallyLoc === finallyLoc) { + this.complete(entry.completion, entry.afterLoc); + resetTryEntry(entry); + return ContinueSentinel; + } + } + }, + "catch": function _catch(tryLoc) { + for (var i = this.tryEntries.length - 1; i >= 0; --i) { + var entry = this.tryEntries[i]; + + if (entry.tryLoc === tryLoc) { + var record = entry.completion; + + if (record.type === "throw") { + var thrown = record.arg; + resetTryEntry(entry); + } + + return thrown; + } + } + + throw new Error("illegal catch attempt"); + }, + delegateYield: function delegateYield(iterable, resultName, nextLoc) { + this.delegate = { + iterator: values(iterable), + resultName: resultName, + nextLoc: nextLoc + }; + + if (this.method === "next") { + this.arg = undefined; + } + + return ContinueSentinel; + } + }; +}(function () { + return this || (typeof self === "undefined" ? "undefined" : _typeof(self)) === "object" && self; +}() || Function("return this")()); +/* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(5)(module))) + +/***/ }), +/* 5 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +module.exports = function (module) { + if (!module.webpackPolyfill) { + module.deprecate = function () {}; + + module.paths = []; + if (!module.children) module.children = []; + Object.defineProperty(module, "loaded", { + enumerable: true, + get: function get() { + return module.l; + } + }); + Object.defineProperty(module, "id", { + enumerable: true, + get: function get() { + return module.i; + } + }); + module.webpackPolyfill = 1; + } + + return module; +}; + +/***/ }), +/* 6 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.isValidRotation = isValidRotation; +exports.isValidScrollMode = isValidScrollMode; +exports.isValidSpreadMode = isValidSpreadMode; +exports.isPortraitOrientation = isPortraitOrientation; +exports.getGlobalEventBus = getGlobalEventBus; +exports.getPDFFileNameFromURL = getPDFFileNameFromURL; +exports.noContextMenuHandler = noContextMenuHandler; +exports.parseQueryString = parseQueryString; +exports.backtrackBeforeAllVisibleElements = backtrackBeforeAllVisibleElements; +exports.getVisibleElements = getVisibleElements; +exports.roundToDivide = roundToDivide; +exports.getPageSizeInches = getPageSizeInches; +exports.approximateFraction = approximateFraction; +exports.getOutputScale = getOutputScale; +exports.scrollIntoView = scrollIntoView; +exports.watchScroll = watchScroll; +exports.binarySearchFirstItem = binarySearchFirstItem; +exports.normalizeWheelEventDelta = normalizeWheelEventDelta; +exports.waitOnEventOrTimeout = waitOnEventOrTimeout; +exports.moveToEndOfArray = moveToEndOfArray; +exports.WaitOnType = exports.animationStarted = exports.ProgressBar = exports.EventBus = exports.NullL10n = exports.SpreadMode = exports.ScrollMode = exports.TextLayerMode = exports.RendererType = exports.PresentationModeState = exports.VERTICAL_PADDING = exports.SCROLLBAR_PADDING = exports.MAX_AUTO_SCALE = exports.UNKNOWN_SCALE = exports.MAX_SCALE = exports.MIN_SCALE = exports.DEFAULT_SCALE = exports.DEFAULT_SCALE_VALUE = exports.CSS_UNITS = void 0; + +var _regenerator = _interopRequireDefault(__webpack_require__(2)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); } + +function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } + +function _iterableToArrayLimit(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } + +function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } + +function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } + +function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; } + +var CSS_UNITS = 96.0 / 72.0; +exports.CSS_UNITS = CSS_UNITS; +var DEFAULT_SCALE_VALUE = 'auto'; +exports.DEFAULT_SCALE_VALUE = DEFAULT_SCALE_VALUE; +var DEFAULT_SCALE = 1.0; +exports.DEFAULT_SCALE = DEFAULT_SCALE; +var MIN_SCALE = 0.10; +exports.MIN_SCALE = MIN_SCALE; +var MAX_SCALE = 10.0; +exports.MAX_SCALE = MAX_SCALE; +var UNKNOWN_SCALE = 0; +exports.UNKNOWN_SCALE = UNKNOWN_SCALE; +var MAX_AUTO_SCALE = 1.25; +exports.MAX_AUTO_SCALE = MAX_AUTO_SCALE; +var SCROLLBAR_PADDING = 40; +exports.SCROLLBAR_PADDING = SCROLLBAR_PADDING; +var VERTICAL_PADDING = 5; +exports.VERTICAL_PADDING = VERTICAL_PADDING; +var PresentationModeState = { + UNKNOWN: 0, + NORMAL: 1, + CHANGING: 2, + FULLSCREEN: 3 +}; +exports.PresentationModeState = PresentationModeState; +var RendererType = { + CANVAS: 'canvas', + SVG: 'svg' +}; +exports.RendererType = RendererType; +var TextLayerMode = { + DISABLE: 0, + ENABLE: 1, + ENABLE_ENHANCE: 2 +}; +exports.TextLayerMode = TextLayerMode; +var ScrollMode = { + UNKNOWN: -1, + VERTICAL: 0, + HORIZONTAL: 1, + WRAPPED: 2 +}; +exports.ScrollMode = ScrollMode; +var SpreadMode = { + UNKNOWN: -1, + NONE: 0, + ODD: 1, + EVEN: 2 +}; +exports.SpreadMode = SpreadMode; + +function formatL10nValue(text, args) { + if (!args) { + return text; + } + + return text.replace(/\{\{\s*(\w+)\s*\}\}/g, function (all, name) { + return name in args ? args[name] : '{{' + name + '}}'; + }); +} + +var NullL10n = { + getLanguage: function () { + var _getLanguage = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee() { + return _regenerator.default.wrap(function _callee$(_context) { + while (1) { + switch (_context.prev = _context.next) { + case 0: + return _context.abrupt("return", 'en-us'); + + case 1: + case "end": + return _context.stop(); + } + } + }, _callee, this); + })); + + function getLanguage() { + return _getLanguage.apply(this, arguments); + } + + return getLanguage; + }(), + getDirection: function () { + var _getDirection = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee2() { + return _regenerator.default.wrap(function _callee2$(_context2) { + while (1) { + switch (_context2.prev = _context2.next) { + case 0: + return _context2.abrupt("return", 'ltr'); + + case 1: + case "end": + return _context2.stop(); + } + } + }, _callee2, this); + })); + + function getDirection() { + return _getDirection.apply(this, arguments); + } + + return getDirection; + }(), + get: function () { + var _get = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee3(property, args, fallback) { + return _regenerator.default.wrap(function _callee3$(_context3) { + while (1) { + switch (_context3.prev = _context3.next) { + case 0: + return _context3.abrupt("return", formatL10nValue(fallback, args)); + + case 1: + case "end": + return _context3.stop(); + } + } + }, _callee3, this); + })); + + function get(_x, _x2, _x3) { + return _get.apply(this, arguments); + } + + return get; + }(), + translate: function () { + var _translate = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee4(element) { + return _regenerator.default.wrap(function _callee4$(_context4) { + while (1) { + switch (_context4.prev = _context4.next) { + case 0: + case "end": + return _context4.stop(); + } + } + }, _callee4, this); + })); + + function translate(_x4) { + return _translate.apply(this, arguments); + } + + return translate; + }() +}; +exports.NullL10n = NullL10n; + +function getOutputScale(ctx) { + var devicePixelRatio = window.devicePixelRatio || 1; + var backingStoreRatio = ctx.webkitBackingStorePixelRatio || ctx.mozBackingStorePixelRatio || ctx.msBackingStorePixelRatio || ctx.oBackingStorePixelRatio || ctx.backingStorePixelRatio || 1; + var pixelRatio = devicePixelRatio / backingStoreRatio; + return { + sx: pixelRatio, + sy: pixelRatio, + scaled: pixelRatio !== 1 + }; +} + +function scrollIntoView(element, spot) { + var skipOverflowHiddenElements = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + var parent = element.offsetParent; + + if (!parent) { + console.error('offsetParent is not set -- cannot scroll'); + return; + } + + var offsetY = element.offsetTop + element.clientTop; + var offsetX = element.offsetLeft + element.clientLeft; + + while (parent.clientHeight === parent.scrollHeight && parent.clientWidth === parent.scrollWidth || skipOverflowHiddenElements && getComputedStyle(parent).overflow === 'hidden') { + if (parent.dataset._scaleY) { + offsetY /= parent.dataset._scaleY; + offsetX /= parent.dataset._scaleX; + } + + offsetY += parent.offsetTop; + offsetX += parent.offsetLeft; + parent = parent.offsetParent; + + if (!parent) { + return; + } + } + + if (spot) { + if (spot.top !== undefined) { + offsetY += spot.top; + } + + if (spot.left !== undefined) { + offsetX += spot.left; + parent.scrollLeft = offsetX; + } + } + + parent.scrollTop = offsetY; +} + +function watchScroll(viewAreaElement, callback) { + var debounceScroll = function debounceScroll(evt) { + if (rAF) { + return; + } + + rAF = window.requestAnimationFrame(function viewAreaElementScrolled() { + rAF = null; + var currentX = viewAreaElement.scrollLeft; + var lastX = state.lastX; + + if (currentX !== lastX) { + state.right = currentX > lastX; + } + + state.lastX = currentX; + var currentY = viewAreaElement.scrollTop; + var lastY = state.lastY; + + if (currentY !== lastY) { + state.down = currentY > lastY; + } + + state.lastY = currentY; + callback(state); + }); + }; + + var state = { + right: true, + down: true, + lastX: viewAreaElement.scrollLeft, + lastY: viewAreaElement.scrollTop, + _eventHandler: debounceScroll + }; + var rAF = null; + viewAreaElement.addEventListener('scroll', debounceScroll, true); + return state; +} + +function parseQueryString(query) { + var parts = query.split('&'); + var params = Object.create(null); + + + // for (var i = 0, ii = parts.length; i < ii; ++i) { + // var param = parts[i].split('='); + // var key = param[0].toLowerCase(); + // var value = param.length > 1 ? param[1] : null; + // console.log(value); + // console.log(key); + // params[decodeURIComponent('file')] += decodeURIComponent(value); + // } + params[decodeURIComponent('file')] = decodeURIComponent(query.substring(5)); + + return params; +} + +function binarySearchFirstItem(items, condition) { + var minIndex = 0; + var maxIndex = items.length - 1; + + if (items.length === 0 || !condition(items[maxIndex])) { + return items.length; + } + + if (condition(items[minIndex])) { + return minIndex; + } + + while (minIndex < maxIndex) { + var currentIndex = minIndex + maxIndex >> 1; + var currentItem = items[currentIndex]; + + if (condition(currentItem)) { + maxIndex = currentIndex; + } else { + minIndex = currentIndex + 1; + } + } + + return minIndex; +} + +function approximateFraction(x) { + if (Math.floor(x) === x) { + return [x, 1]; + } + + var xinv = 1 / x; + var limit = 8; + + if (xinv > limit) { + return [1, limit]; + } else if (Math.floor(xinv) === xinv) { + return [1, xinv]; + } + + var x_ = x > 1 ? xinv : x; + var a = 0, + b = 1, + c = 1, + d = 1; + + while (true) { + var p = a + c, + q = b + d; + + if (q > limit) { + break; + } + + if (x_ <= p / q) { + c = p; + d = q; + } else { + a = p; + b = q; + } + } + + var result; + + if (x_ - a / b < c / d - x_) { + result = x_ === x ? [a, b] : [b, a]; + } else { + result = x_ === x ? [c, d] : [d, c]; + } + + return result; +} + +function roundToDivide(x, div) { + var r = x % div; + return r === 0 ? x : Math.round(x - r + div); +} + +function getPageSizeInches(_ref) { + var view = _ref.view, + userUnit = _ref.userUnit, + rotate = _ref.rotate; + + var _view = _slicedToArray(view, 4), + x1 = _view[0], + y1 = _view[1], + x2 = _view[2], + y2 = _view[3]; + + var changeOrientation = rotate % 180 !== 0; + var width = (x2 - x1) / 72 * userUnit; + var height = (y2 - y1) / 72 * userUnit; + return { + width: changeOrientation ? height : width, + height: changeOrientation ? width : height + }; +} + +function backtrackBeforeAllVisibleElements(index, views, top) { + if (index < 2) { + return index; + } + + var elt = views[index].div; + var pageTop = elt.offsetTop + elt.clientTop; + + if (pageTop >= top) { + elt = views[index - 1].div; + pageTop = elt.offsetTop + elt.clientTop; + } + + for (var i = index - 2; i >= 0; --i) { + elt = views[i].div; + + if (elt.offsetTop + elt.clientTop + elt.clientHeight <= pageTop) { + break; + } + + index = i; + } + + return index; +} + +function getVisibleElements(scrollEl, views) { + var sortByVisibility = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + var horizontal = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; + var top = scrollEl.scrollTop, + bottom = top + scrollEl.clientHeight; + var left = scrollEl.scrollLeft, + right = left + scrollEl.clientWidth; + + function isElementBottomAfterViewTop(view) { + var element = view.div; + var elementBottom = element.offsetTop + element.clientTop + element.clientHeight; + return elementBottom > top; + } + + function isElementRightAfterViewLeft(view) { + var element = view.div; + var elementRight = element.offsetLeft + element.clientLeft + element.clientWidth; + return elementRight > left; + } + + var visible = [], + numViews = views.length; + var firstVisibleElementInd = numViews === 0 ? 0 : binarySearchFirstItem(views, horizontal ? isElementRightAfterViewLeft : isElementBottomAfterViewTop); + + if (firstVisibleElementInd > 0 && firstVisibleElementInd < numViews && !horizontal) { + firstVisibleElementInd = backtrackBeforeAllVisibleElements(firstVisibleElementInd, views, top); + } + + var lastEdge = horizontal ? right : -1; + + for (var i = firstVisibleElementInd; i < numViews; i++) { + var view = views[i], + element = view.div; + var currentWidth = element.offsetLeft + element.clientLeft; + var currentHeight = element.offsetTop + element.clientTop; + var viewWidth = element.clientWidth, + viewHeight = element.clientHeight; + var viewRight = currentWidth + viewWidth; + var viewBottom = currentHeight + viewHeight; + + if (lastEdge === -1) { + if (viewBottom >= bottom) { + lastEdge = viewBottom; + } + } else if ((horizontal ? currentWidth : currentHeight) > lastEdge) { + break; + } + + if (viewBottom <= top || currentHeight >= bottom || viewRight <= left || currentWidth >= right) { + continue; + } + + var hiddenHeight = Math.max(0, top - currentHeight) + Math.max(0, viewBottom - bottom); + var hiddenWidth = Math.max(0, left - currentWidth) + Math.max(0, viewRight - right); + var percent = (viewHeight - hiddenHeight) * (viewWidth - hiddenWidth) * 100 / viewHeight / viewWidth | 0; + visible.push({ + id: view.id, + x: currentWidth, + y: currentHeight, + view: view, + percent: percent + }); + } + + var first = visible[0], + last = visible[visible.length - 1]; + + if (sortByVisibility) { + visible.sort(function (a, b) { + var pc = a.percent - b.percent; + + if (Math.abs(pc) > 0.001) { + return -pc; + } + + return a.id - b.id; + }); + } + + return { + first: first, + last: last, + views: visible + }; +} + +function noContextMenuHandler(evt) { + evt.preventDefault(); +} + +function isDataSchema(url) { + var i = 0, + ii = url.length; + + while (i < ii && url[i].trim() === '') { + i++; + } + + return url.substring(i, i + 5).toLowerCase() === 'data:'; +} + +function getPDFFileNameFromURL(url) { + var defaultFilename = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'document.pdf'; + + if (typeof url !== 'string') { + return defaultFilename; + } + + if (isDataSchema(url)) { + console.warn('getPDFFileNameFromURL: ' + 'ignoring "data:" URL for performance reasons.'); + return defaultFilename; + } + + var reURI = /^(?:(?:[^:]+:)?\/\/[^\/]+)?([^?#]*)(\?[^#]*)?(#.*)?$/; + var reFilename = /[^\/?#=]+\.pdf\b(?!.*\.pdf\b)/i; + var splitURI = reURI.exec(url); + var suggestedFilename = reFilename.exec(splitURI[1]) || reFilename.exec(splitURI[2]) || reFilename.exec(splitURI[3]); + + if (suggestedFilename) { + suggestedFilename = suggestedFilename[0]; + + if (suggestedFilename.includes('%')) { + try { + suggestedFilename = reFilename.exec(decodeURIComponent(suggestedFilename))[0]; + } catch (ex) {} + } + } + + return suggestedFilename || defaultFilename; +} + +function normalizeWheelEventDelta(evt) { + var delta = Math.sqrt(evt.deltaX * evt.deltaX + evt.deltaY * evt.deltaY); + var angle = Math.atan2(evt.deltaY, evt.deltaX); + + if (-0.25 * Math.PI < angle && angle < 0.75 * Math.PI) { + delta = -delta; + } + + var MOUSE_DOM_DELTA_PIXEL_MODE = 0; + var MOUSE_DOM_DELTA_LINE_MODE = 1; + var MOUSE_PIXELS_PER_LINE = 30; + var MOUSE_LINES_PER_PAGE = 30; + + if (evt.deltaMode === MOUSE_DOM_DELTA_PIXEL_MODE) { + delta /= MOUSE_PIXELS_PER_LINE * MOUSE_LINES_PER_PAGE; + } else if (evt.deltaMode === MOUSE_DOM_DELTA_LINE_MODE) { + delta /= MOUSE_LINES_PER_PAGE; + } + + return delta; +} + +function isValidRotation(angle) { + return Number.isInteger(angle) && angle % 90 === 0; +} + +function isValidScrollMode(mode) { + return Number.isInteger(mode) && Object.values(ScrollMode).includes(mode) && mode !== ScrollMode.UNKNOWN; +} + +function isValidSpreadMode(mode) { + return Number.isInteger(mode) && Object.values(SpreadMode).includes(mode) && mode !== SpreadMode.UNKNOWN; +} + +function isPortraitOrientation(size) { + return size.width <= size.height; +} + +var WaitOnType = { + EVENT: 'event', + TIMEOUT: 'timeout' +}; +exports.WaitOnType = WaitOnType; + +function waitOnEventOrTimeout(_ref2) { + var target = _ref2.target, + name = _ref2.name, + _ref2$delay = _ref2.delay, + delay = _ref2$delay === void 0 ? 0 : _ref2$delay; + return new Promise(function (resolve, reject) { + if (_typeof(target) !== 'object' || !(name && typeof name === 'string') || !(Number.isInteger(delay) && delay >= 0)) { + throw new Error('waitOnEventOrTimeout - invalid parameters.'); + } + + function handler(type) { + if (target instanceof EventBus) { + target.off(name, eventHandler); + } else { + target.removeEventListener(name, eventHandler); + } + + if (timeout) { + clearTimeout(timeout); + } + + resolve(type); + } + + var eventHandler = handler.bind(null, WaitOnType.EVENT); + + if (target instanceof EventBus) { + target.on(name, eventHandler); + } else { + target.addEventListener(name, eventHandler); + } + + var timeoutHandler = handler.bind(null, WaitOnType.TIMEOUT); + var timeout = setTimeout(timeoutHandler, delay); + }); +} + +var animationStarted = new Promise(function (resolve) { + window.requestAnimationFrame(resolve); +}); +exports.animationStarted = animationStarted; + +var EventBus = +/*#__PURE__*/ +function () { + function EventBus() { + var _ref3 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, + _ref3$dispatchToDOM = _ref3.dispatchToDOM, + dispatchToDOM = _ref3$dispatchToDOM === void 0 ? false : _ref3$dispatchToDOM; + + _classCallCheck(this, EventBus); + + this._listeners = Object.create(null); + this._dispatchToDOM = dispatchToDOM === true; + } + + _createClass(EventBus, [{ + key: "on", + value: function on(eventName, listener) { + var eventListeners = this._listeners[eventName]; + + if (!eventListeners) { + eventListeners = []; + this._listeners[eventName] = eventListeners; + } + + eventListeners.push(listener); + } + }, { + key: "off", + value: function off(eventName, listener) { + var eventListeners = this._listeners[eventName]; + var i; + + if (!eventListeners || (i = eventListeners.indexOf(listener)) < 0) { + return; + } + + eventListeners.splice(i, 1); + } + }, { + key: "dispatch", + value: function dispatch(eventName) { + var eventListeners = this._listeners[eventName]; + + if (!eventListeners || eventListeners.length === 0) { + if (this._dispatchToDOM) { + var _args5 = Array.prototype.slice.call(arguments, 1); + + this._dispatchDOMEvent(eventName, _args5); + } + + return; + } + + var args = Array.prototype.slice.call(arguments, 1); + eventListeners.slice(0).forEach(function (listener) { + listener.apply(null, args); + }); + + if (this._dispatchToDOM) { + this._dispatchDOMEvent(eventName, args); + } + } + }, { + key: "_dispatchDOMEvent", + value: function _dispatchDOMEvent(eventName) { + var args = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; + var details = Object.create(null); + + if (args && args.length > 0) { + var obj = args[0]; + + for (var key in obj) { + var value = obj[key]; + + if (key === 'source') { + if (value === window || value === document) { + return; + } + + continue; + } + + details[key] = value; + } + } + + var event = document.createEvent('CustomEvent'); + event.initCustomEvent(eventName, true, true, details); + document.dispatchEvent(event); + } + }]); + + return EventBus; +}(); + +exports.EventBus = EventBus; +var globalEventBus = null; + +function getGlobalEventBus() { + var dispatchToDOM = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + + if (!globalEventBus) { + globalEventBus = new EventBus({ + dispatchToDOM: dispatchToDOM + }); + } + + return globalEventBus; +} + +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +var ProgressBar = +/*#__PURE__*/ +function () { + function ProgressBar(id) { + var _ref4 = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, + height = _ref4.height, + width = _ref4.width, + units = _ref4.units; + + _classCallCheck(this, ProgressBar); + + this.visible = true; + this.div = document.querySelector(id + ' .progress'); + this.bar = this.div.parentNode; + this.height = height || 100; + this.width = width || 100; + this.units = units || '%'; + this.div.style.height = this.height + this.units; + this.percent = 0; + } + + _createClass(ProgressBar, [{ + key: "_updateBar", + value: function _updateBar() { + if (this._indeterminate) { + this.div.classList.add('indeterminate'); + this.div.style.width = this.width + this.units; + return; + } + + this.div.classList.remove('indeterminate'); + var progressSize = this.width * this._percent / 100; + this.div.style.width = progressSize + this.units; + } + }, { + key: "setWidth", + value: function setWidth(viewer) { + if (!viewer) { + return; + } + + var container = viewer.parentNode; + var scrollbarWidth = container.offsetWidth - viewer.offsetWidth; + + if (scrollbarWidth > 0) { + this.bar.setAttribute('style', 'width: calc(100% - ' + scrollbarWidth + 'px);'); + } + } + }, { + key: "hide", + value: function hide() { + if (!this.visible) { + return; + } + + this.visible = false; + this.bar.classList.add('hidden'); + document.body.classList.remove('loadingInProgress'); + } + }, { + key: "show", + value: function show() { + if (this.visible) { + return; + } + + this.visible = true; + document.body.classList.add('loadingInProgress'); + this.bar.classList.remove('hidden'); + } + }, { + key: "percent", + get: function get() { + return this._percent; + }, + set: function set(val) { + this._indeterminate = isNaN(val); + this._percent = clamp(val, 0, 100); + + this._updateBar(); + } + }]); + + return ProgressBar; +}(); + +exports.ProgressBar = ProgressBar; + +function moveToEndOfArray(arr, condition) { + var moved = [], + len = arr.length; + var write = 0; + + for (var read = 0; read < len; ++read) { + if (condition(arr[read])) { + moved.push(arr[read]); + } else { + arr[write] = arr[read]; + ++write; + } + } + + for (var _read = 0; write < len; ++_read, ++write) { + arr[write] = moved[_read]; + } +} + +/***/ }), +/* 7 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +var pdfjsLib; + +if (typeof window !== 'undefined' && window['pdfjs-dist/build/pdf']) { + pdfjsLib = window['pdfjs-dist/build/pdf']; +} else { + pdfjsLib = require('../build/pdf.js'); +} + +module.exports = pdfjsLib; + +/***/ }), +/* 8 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFCursorTools = exports.CursorTool = void 0; + +var _grab_to_pan = __webpack_require__(9); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var CursorTool = { + SELECT: 0, + HAND: 1, + ZOOM: 2 +}; +exports.CursorTool = CursorTool; + +var PDFCursorTools = +/*#__PURE__*/ +function () { + function PDFCursorTools(_ref) { + var _this = this; + + var container = _ref.container, + eventBus = _ref.eventBus, + _ref$cursorToolOnLoad = _ref.cursorToolOnLoad, + cursorToolOnLoad = _ref$cursorToolOnLoad === void 0 ? CursorTool.SELECT : _ref$cursorToolOnLoad; + + _classCallCheck(this, PDFCursorTools); + + this.container = container; + this.eventBus = eventBus; + this.active = CursorTool.SELECT; + this.activeBeforePresentationMode = null; + this.handTool = new _grab_to_pan.GrabToPan({ + element: this.container + }); + + this._addEventListeners(); + + Promise.resolve().then(function () { + _this.switchTool(cursorToolOnLoad); + }); + } + + _createClass(PDFCursorTools, [{ + key: "switchTool", + value: function switchTool(tool) { + var _this2 = this; + + if (this.activeBeforePresentationMode !== null) { + return; + } + + if (tool === this.active) { + return; + } + + var disableActiveTool = function disableActiveTool() { + switch (_this2.active) { + case CursorTool.SELECT: + break; + + case CursorTool.HAND: + _this2.handTool.deactivate(); + + break; + + case CursorTool.ZOOM: + } + }; + + switch (tool) { + case CursorTool.SELECT: + disableActiveTool(); + break; + + case CursorTool.HAND: + disableActiveTool(); + this.handTool.activate(); + break; + + case CursorTool.ZOOM: + default: + console.error("switchTool: \"".concat(tool, "\" is an unsupported value.")); + return; + } + + this.active = tool; + + this._dispatchEvent(); + } + }, { + key: "_dispatchEvent", + value: function _dispatchEvent() { + this.eventBus.dispatch('cursortoolchanged', { + source: this, + tool: this.active + }); + } + }, { + key: "_addEventListeners", + value: function _addEventListeners() { + var _this3 = this; + + this.eventBus.on('switchcursortool', function (evt) { + _this3.switchTool(evt.tool); + }); + this.eventBus.on('presentationmodechanged', function (evt) { + if (evt.switchInProgress) { + return; + } + + var previouslyActive; + + if (evt.active) { + previouslyActive = _this3.active; + + _this3.switchTool(CursorTool.SELECT); + + _this3.activeBeforePresentationMode = previouslyActive; + } else { + previouslyActive = _this3.activeBeforePresentationMode; + _this3.activeBeforePresentationMode = null; + + _this3.switchTool(previouslyActive); + } + }); + } + }, { + key: "activeTool", + get: function get() { + return this.active; + } + }]); + + return PDFCursorTools; +}(); + +exports.PDFCursorTools = PDFCursorTools; + +/***/ }), +/* 9 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.GrabToPan = GrabToPan; + +function GrabToPan(options) { + this.element = options.element; + this.document = options.element.ownerDocument; + + if (typeof options.ignoreTarget === 'function') { + this.ignoreTarget = options.ignoreTarget; + } + + this.onActiveChanged = options.onActiveChanged; + this.activate = this.activate.bind(this); + this.deactivate = this.deactivate.bind(this); + this.toggle = this.toggle.bind(this); + this._onmousedown = this._onmousedown.bind(this); + this._onmousemove = this._onmousemove.bind(this); + this._endPan = this._endPan.bind(this); + var overlay = this.overlay = document.createElement('div'); + overlay.className = 'grab-to-pan-grabbing'; +} + +GrabToPan.prototype = { + CSS_CLASS_GRAB: 'grab-to-pan-grab', + activate: function GrabToPan_activate() { + if (!this.active) { + this.active = true; + this.element.addEventListener('mousedown', this._onmousedown, true); + this.element.classList.add(this.CSS_CLASS_GRAB); + + if (this.onActiveChanged) { + this.onActiveChanged(true); + } + } + }, + deactivate: function GrabToPan_deactivate() { + if (this.active) { + this.active = false; + this.element.removeEventListener('mousedown', this._onmousedown, true); + + this._endPan(); + + this.element.classList.remove(this.CSS_CLASS_GRAB); + + if (this.onActiveChanged) { + this.onActiveChanged(false); + } + } + }, + toggle: function GrabToPan_toggle() { + if (this.active) { + this.deactivate(); + } else { + this.activate(); + } + }, + ignoreTarget: function GrabToPan_ignoreTarget(node) { + return node[matchesSelector]('a[href], a[href] *, input, textarea, button, button *, select, option'); + }, + _onmousedown: function GrabToPan__onmousedown(event) { + if (event.button !== 0 || this.ignoreTarget(event.target)) { + return; + } + + if (event.originalTarget) { + try { + event.originalTarget.tagName; + } catch (e) { + return; + } + } + + this.scrollLeftStart = this.element.scrollLeft; + this.scrollTopStart = this.element.scrollTop; + this.clientXStart = event.clientX; + this.clientYStart = event.clientY; + this.document.addEventListener('mousemove', this._onmousemove, true); + this.document.addEventListener('mouseup', this._endPan, true); + this.element.addEventListener('scroll', this._endPan, true); + event.preventDefault(); + event.stopPropagation(); + var focusedElement = document.activeElement; + + if (focusedElement && !focusedElement.contains(event.target)) { + focusedElement.blur(); + } + }, + _onmousemove: function GrabToPan__onmousemove(event) { + this.element.removeEventListener('scroll', this._endPan, true); + + if (isLeftMouseReleased(event)) { + this._endPan(); + + return; + } + + var xDiff = event.clientX - this.clientXStart; + var yDiff = event.clientY - this.clientYStart; + var scrollTop = this.scrollTopStart - yDiff; + var scrollLeft = this.scrollLeftStart - xDiff; + + if (this.element.scrollTo) { + this.element.scrollTo({ + top: scrollTop, + left: scrollLeft, + behavior: 'instant' + }); + } else { + this.element.scrollTop = scrollTop; + this.element.scrollLeft = scrollLeft; + } + + if (!this.overlay.parentNode) { + document.body.appendChild(this.overlay); + } + }, + _endPan: function GrabToPan__endPan() { + this.element.removeEventListener('scroll', this._endPan, true); + this.document.removeEventListener('mousemove', this._onmousemove, true); + this.document.removeEventListener('mouseup', this._endPan, true); + this.overlay.remove(); + } +}; +var matchesSelector; +['webkitM', 'mozM', 'msM', 'oM', 'm'].some(function (prefix) { + var name = prefix + 'atches'; + + if (name in document.documentElement) { + matchesSelector = name; + } + + name += 'Selector'; + + if (name in document.documentElement) { + matchesSelector = name; + } + + return matchesSelector; +}); +var isNotIEorIsIE10plus = !document.documentMode || document.documentMode > 9; +var chrome = window.chrome; +var isChrome15OrOpera15plus = chrome && (chrome.webstore || chrome.app); +var isSafari6plus = /Apple/.test(navigator.vendor) && /Version\/([6-9]\d*|[1-5]\d+)/.test(navigator.userAgent); + +function isLeftMouseReleased(event) { + if ('buttons' in event && isNotIEorIsIE10plus) { + return !(event.buttons & 1); + } + + if (isChrome15OrOpera15plus || isSafari6plus) { + return event.which === 0; + } +} + +/***/ }), +/* 10 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFRenderingQueue = exports.RenderingStates = void 0; + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var CLEANUP_TIMEOUT = 30000; +var RenderingStates = { + INITIAL: 0, + RUNNING: 1, + PAUSED: 2, + FINISHED: 3 +}; +exports.RenderingStates = RenderingStates; + +var PDFRenderingQueue = +/*#__PURE__*/ +function () { + function PDFRenderingQueue() { + _classCallCheck(this, PDFRenderingQueue); + + this.pdfViewer = null; + this.pdfThumbnailViewer = null; + this.onIdle = null; + this.highestPriorityPage = null; + this.idleTimeout = null; + this.printing = false; + this.isThumbnailViewEnabled = false; + } + + _createClass(PDFRenderingQueue, [{ + key: "setViewer", + value: function setViewer(pdfViewer) { + this.pdfViewer = pdfViewer; + } + }, { + key: "setThumbnailViewer", + value: function setThumbnailViewer(pdfThumbnailViewer) { + this.pdfThumbnailViewer = pdfThumbnailViewer; + } + }, { + key: "isHighestPriority", + value: function isHighestPriority(view) { + return this.highestPriorityPage === view.renderingId; + } + }, { + key: "renderHighestPriority", + value: function renderHighestPriority(currentlyVisiblePages) { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + + if (this.pdfViewer.forceRendering(currentlyVisiblePages)) { + return; + } + + if (this.pdfThumbnailViewer && this.isThumbnailViewEnabled) { + if (this.pdfThumbnailViewer.forceRendering()) { + return; + } + } + + if (this.printing) { + return; + } + + if (this.onIdle) { + this.idleTimeout = setTimeout(this.onIdle.bind(this), CLEANUP_TIMEOUT); + } + } + }, { + key: "getHighestPriority", + value: function getHighestPriority(visible, views, scrolledDown) { + var visibleViews = visible.views; + var numVisible = visibleViews.length; + + if (numVisible === 0) { + return false; + } + + for (var i = 0; i < numVisible; ++i) { + var view = visibleViews[i].view; + + if (!this.isViewFinished(view)) { + return view; + } + } + + if (scrolledDown) { + var nextPageIndex = visible.last.id; + + if (views[nextPageIndex] && !this.isViewFinished(views[nextPageIndex])) { + return views[nextPageIndex]; + } + } else { + var previousPageIndex = visible.first.id - 2; + + if (views[previousPageIndex] && !this.isViewFinished(views[previousPageIndex])) { + return views[previousPageIndex]; + } + } + + return null; + } + }, { + key: "isViewFinished", + value: function isViewFinished(view) { + return view.renderingState === RenderingStates.FINISHED; + } + }, { + key: "renderView", + value: function renderView(view) { + var _this = this; + + switch (view.renderingState) { + case RenderingStates.FINISHED: + return false; + + case RenderingStates.PAUSED: + this.highestPriorityPage = view.renderingId; + view.resume(); + break; + + case RenderingStates.RUNNING: + this.highestPriorityPage = view.renderingId; + break; + + case RenderingStates.INITIAL: + this.highestPriorityPage = view.renderingId; + + var continueRendering = function continueRendering() { + _this.renderHighestPriority(); + }; + + view.draw().then(continueRendering, continueRendering); + break; + } + + return true; + } + }]); + + return PDFRenderingQueue; +}(); + +exports.PDFRenderingQueue = PDFRenderingQueue; + +/***/ }), +/* 11 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFSidebar = exports.SidebarView = void 0; + +var _ui_utils = __webpack_require__(6); + +var _pdf_rendering_queue = __webpack_require__(10); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var UI_NOTIFICATION_CLASS = 'pdfSidebarNotification'; +var SidebarView = { + UNKNOWN: -1, + NONE: 0, + THUMBS: 1, + OUTLINE: 2, + ATTACHMENTS: 3, + LAYERS: 4 +}; +exports.SidebarView = SidebarView; + +var PDFSidebar = +/*#__PURE__*/ +function () { + function PDFSidebar(options, eventBus) { + var l10n = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : _ui_utils.NullL10n; + + _classCallCheck(this, PDFSidebar); + + this.isOpen = false; + this.active = SidebarView.THUMBS; + this.isInitialViewSet = false; + this.onToggled = null; + this.pdfViewer = options.pdfViewer; + this.pdfThumbnailViewer = options.pdfThumbnailViewer; + this.outerContainer = options.outerContainer; + this.viewerContainer = options.viewerContainer; + this.toggleButton = options.toggleButton; + this.thumbnailButton = options.thumbnailButton; + this.outlineButton = options.outlineButton; + this.attachmentsButton = options.attachmentsButton; + this.thumbnailView = options.thumbnailView; + this.outlineView = options.outlineView; + this.attachmentsView = options.attachmentsView; + this.disableNotification = options.disableNotification || false; + this.eventBus = eventBus; + this.l10n = l10n; + + this._addEventListeners(); + } + + _createClass(PDFSidebar, [{ + key: "reset", + value: function reset() { + this.isInitialViewSet = false; + + this._hideUINotification(null); + + this.switchView(SidebarView.THUMBS); + this.outlineButton.disabled = false; + this.attachmentsButton.disabled = false; + } + }, { + key: "setInitialView", + value: function setInitialView() { + var view = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : SidebarView.NONE; + + if (this.isInitialViewSet) { + return; + } + + this.isInitialViewSet = true; + + if (view === SidebarView.NONE || view === SidebarView.UNKNOWN) { + this._dispatchEvent(); + + return; + } + + if (!this._switchView(view, true)) { + this._dispatchEvent(); + } + } + }, { + key: "switchView", + value: function switchView(view) { + var forceOpen = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + + this._switchView(view, forceOpen); + } + }, { + key: "_switchView", + value: function _switchView(view) { + var forceOpen = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + var isViewChanged = view !== this.active; + var shouldForceRendering = false; + + switch (view) { + case SidebarView.NONE: + if (this.isOpen) { + this.close(); + return true; + } + + return false; + + case SidebarView.THUMBS: + if (this.isOpen && isViewChanged) { + shouldForceRendering = true; + } + + break; + + case SidebarView.OUTLINE: + if (this.outlineButton.disabled) { + return false; + } + + break; + + case SidebarView.ATTACHMENTS: + if (this.attachmentsButton.disabled) { + return false; + } + + break; + + default: + console.error("PDFSidebar._switchView: \"".concat(view, "\" is not a valid view.")); + return false; + } + + this.active = view; + this.thumbnailButton.classList.toggle('toggled', view === SidebarView.THUMBS); + this.outlineButton.classList.toggle('toggled', view === SidebarView.OUTLINE); + this.attachmentsButton.classList.toggle('toggled', view === SidebarView.ATTACHMENTS); + this.thumbnailView.classList.toggle('hidden', view !== SidebarView.THUMBS); + this.outlineView.classList.toggle('hidden', view !== SidebarView.OUTLINE); + this.attachmentsView.classList.toggle('hidden', view !== SidebarView.ATTACHMENTS); + + if (forceOpen && !this.isOpen) { + this.open(); + return true; + } + + if (shouldForceRendering) { + this._updateThumbnailViewer(); + + this._forceRendering(); + } + + if (isViewChanged) { + this._dispatchEvent(); + } + + this._hideUINotification(this.active); + + return isViewChanged; + } + }, { + key: "open", + value: function open() { + if (this.isOpen) { + return; + } + + this.isOpen = true; + this.toggleButton.classList.add('toggled'); + this.outerContainer.classList.add('sidebarMoving', 'sidebarOpen'); + + if (this.active === SidebarView.THUMBS) { + this._updateThumbnailViewer(); + } + + this._forceRendering(); + + this._dispatchEvent(); + + this._hideUINotification(this.active); + } + }, { + key: "close", + value: function close() { + if (!this.isOpen) { + return; + } + + this.isOpen = false; + this.toggleButton.classList.remove('toggled'); + this.outerContainer.classList.add('sidebarMoving'); + this.outerContainer.classList.remove('sidebarOpen'); + + this._forceRendering(); + + this._dispatchEvent(); + } + }, { + key: "toggle", + value: function toggle() { + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + } + }, { + key: "_dispatchEvent", + value: function _dispatchEvent() { + this.eventBus.dispatch('sidebarviewchanged', { + source: this, + view: this.visibleView + }); + } + }, { + key: "_forceRendering", + value: function _forceRendering() { + if (this.onToggled) { + this.onToggled(); + } else { + this.pdfViewer.forceRendering(); + this.pdfThumbnailViewer.forceRendering(); + } + } + }, { + key: "_updateThumbnailViewer", + value: function _updateThumbnailViewer() { + var pdfViewer = this.pdfViewer, + pdfThumbnailViewer = this.pdfThumbnailViewer; + var pagesCount = pdfViewer.pagesCount; + + for (var pageIndex = 0; pageIndex < pagesCount; pageIndex++) { + var pageView = pdfViewer.getPageView(pageIndex); + + if (pageView && pageView.renderingState === _pdf_rendering_queue.RenderingStates.FINISHED) { + var thumbnailView = pdfThumbnailViewer.getThumbnail(pageIndex); + thumbnailView.setImage(pageView); + } + } + + pdfThumbnailViewer.scrollThumbnailIntoView(pdfViewer.currentPageNumber); + } + }, { + key: "_showUINotification", + value: function _showUINotification(view) { + var _this = this; + + if (this.disableNotification) { + return; + } + + this.l10n.get('toggle_sidebar_notification.title', null, 'Toggle Sidebar (document contains outline/attachments)').then(function (msg) { + _this.toggleButton.title = msg; + }); + + if (!this.isOpen) { + this.toggleButton.classList.add(UI_NOTIFICATION_CLASS); + } else if (view === this.active) { + return; + } + + switch (view) { + case SidebarView.OUTLINE: + this.outlineButton.classList.add(UI_NOTIFICATION_CLASS); + break; + + case SidebarView.ATTACHMENTS: + this.attachmentsButton.classList.add(UI_NOTIFICATION_CLASS); + break; + } + } + }, { + key: "_hideUINotification", + value: function _hideUINotification(view) { + var _this2 = this; + + if (this.disableNotification) { + return; + } + + var removeNotification = function removeNotification(view) { + switch (view) { + case SidebarView.OUTLINE: + _this2.outlineButton.classList.remove(UI_NOTIFICATION_CLASS); + + break; + + case SidebarView.ATTACHMENTS: + _this2.attachmentsButton.classList.remove(UI_NOTIFICATION_CLASS); + + break; + } + }; + + if (!this.isOpen && view !== null) { + return; + } + + this.toggleButton.classList.remove(UI_NOTIFICATION_CLASS); + + if (view !== null) { + removeNotification(view); + return; + } + + for (view in SidebarView) { + removeNotification(SidebarView[view]); + } + + this.l10n.get('toggle_sidebar.title', null, 'Toggle Sidebar').then(function (msg) { + _this2.toggleButton.title = msg; + }); + } + }, { + key: "_addEventListeners", + value: function _addEventListeners() { + var _this3 = this; + + this.viewerContainer.addEventListener('transitionend', function (evt) { + if (evt.target === _this3.viewerContainer) { + _this3.outerContainer.classList.remove('sidebarMoving'); + } + }); + this.thumbnailButton.addEventListener('click', function () { + _this3.switchView(SidebarView.THUMBS); + }); + this.outlineButton.addEventListener('click', function () { + _this3.switchView(SidebarView.OUTLINE); + }); + this.outlineButton.addEventListener('dblclick', function () { + _this3.eventBus.dispatch('toggleoutlinetree', { + source: _this3 + }); + }); + this.attachmentsButton.addEventListener('click', function () { + _this3.switchView(SidebarView.ATTACHMENTS); + }); + this.eventBus.on('outlineloaded', function (evt) { + var outlineCount = evt.outlineCount; + _this3.outlineButton.disabled = !outlineCount; + + if (outlineCount) { + _this3._showUINotification(SidebarView.OUTLINE); + } else if (_this3.active === SidebarView.OUTLINE) { + _this3.switchView(SidebarView.THUMBS); + } + }); + this.eventBus.on('attachmentsloaded', function (evt) { + if (evt.attachmentsCount) { + _this3.attachmentsButton.disabled = false; + + _this3._showUINotification(SidebarView.ATTACHMENTS); + + return; + } + + Promise.resolve().then(function () { + if (_this3.attachmentsView.hasChildNodes()) { + return; + } + + _this3.attachmentsButton.disabled = true; + + if (_this3.active === SidebarView.ATTACHMENTS) { + _this3.switchView(SidebarView.THUMBS); + } + }); + }); + this.eventBus.on('presentationmodechanged', function (evt) { + if (!evt.active && !evt.switchInProgress && _this3.isThumbnailViewVisible) { + _this3._updateThumbnailViewer(); + } + }); + } + }, { + key: "visibleView", + get: function get() { + return this.isOpen ? this.active : SidebarView.NONE; + } + }, { + key: "isThumbnailViewVisible", + get: function get() { + return this.isOpen && this.active === SidebarView.THUMBS; + } + }, { + key: "isOutlineViewVisible", + get: function get() { + return this.isOpen && this.active === SidebarView.OUTLINE; + } + }, { + key: "isAttachmentsViewVisible", + get: function get() { + return this.isOpen && this.active === SidebarView.ATTACHMENTS; + } + }]); + + return PDFSidebar; +}(); + +exports.PDFSidebar = PDFSidebar; + +/***/ }), +/* 12 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.OptionKind = exports.AppOptions = void 0; + +var _pdfjsLib = __webpack_require__(7); + +var _viewer_compatibility = __webpack_require__(13); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var OptionKind = { + VIEWER: 'viewer', + API: 'api', + WORKER: 'worker' +}; +exports.OptionKind = OptionKind; +var defaultOptions = { + cursorToolOnLoad: { + value: 0, + kind: OptionKind.VIEWER + }, + defaultUrl: { + value: 'compressed.tracemonkey-pldi-09.pdf', + kind: OptionKind.VIEWER + }, + defaultZoomValue: { + value: '', + kind: OptionKind.VIEWER + }, + disableHistory: { + value: false, + kind: OptionKind.VIEWER + }, + disablePageLabels: { + value: false, + kind: OptionKind.VIEWER + }, + enablePrintAutoRotate: { + value: false, + kind: OptionKind.VIEWER + }, + enableWebGL: { + value: false, + kind: OptionKind.VIEWER + }, + eventBusDispatchToDOM: { + value: false, + kind: OptionKind.VIEWER + }, + externalLinkRel: { + value: 'noopener noreferrer nofollow', + kind: OptionKind.VIEWER + }, + externalLinkTarget: { + value: 0, + kind: OptionKind.VIEWER + }, + historyUpdateUrl: { + value: false, + kind: OptionKind.VIEWER + }, + imageResourcesPath: { + value: './images/', + kind: OptionKind.VIEWER + }, + maxCanvasPixels: { + value: 16777216, + compatibility: _viewer_compatibility.viewerCompatibilityParams.maxCanvasPixels, + kind: OptionKind.VIEWER + }, + pdfBugEnabled: { + value: false, + kind: OptionKind.VIEWER + }, + renderer: { + value: 'canvas', + kind: OptionKind.VIEWER + }, + renderInteractiveForms: { + value: false, + kind: OptionKind.VIEWER + }, + sidebarViewOnLoad: { + value: -1, + kind: OptionKind.VIEWER + }, + scrollModeOnLoad: { + value: -1, + kind: OptionKind.VIEWER + }, + spreadModeOnLoad: { + value: -1, + kind: OptionKind.VIEWER + }, + textLayerMode: { + value: 1, + kind: OptionKind.VIEWER + }, + useOnlyCssZoom: { + value: false, + kind: OptionKind.VIEWER + }, + viewOnLoad: { + value: 0, + kind: OptionKind.VIEWER + }, + cMapPacked: { + value: true, + kind: OptionKind.API + }, + cMapUrl: { + value: '../web/cmaps/', + kind: OptionKind.API + }, + disableAutoFetch: { + value: false, + kind: OptionKind.API + }, + disableCreateObjectURL: { + value: false, + compatibility: _pdfjsLib.apiCompatibilityParams.disableCreateObjectURL, + kind: OptionKind.API + }, + disableFontFace: { + value: false, + kind: OptionKind.API + }, + disableRange: { + value: false, + kind: OptionKind.API + }, + disableStream: { + value: false, + kind: OptionKind.API + }, + isEvalSupported: { + value: true, + kind: OptionKind.API + }, + maxImageSize: { + value: -1, + kind: OptionKind.API + }, + pdfBug: { + value: false, + kind: OptionKind.API + }, + postMessageTransfers: { + value: true, + kind: OptionKind.API + }, + verbosity: { + value: 1, + kind: OptionKind.API + }, + workerPort: { + value: null, + kind: OptionKind.WORKER + }, + workerSrc: { + value: '../build/pdf.worker.js', + kind: OptionKind.WORKER + } +}; +{ + defaultOptions.disablePreferences = { + value: false, + kind: OptionKind.VIEWER + }; + defaultOptions.locale = { + value: typeof navigator !== 'undefined' ? navigator.language : 'en-US', + kind: OptionKind.VIEWER + }; +} +var userOptions = Object.create(null); + +var AppOptions = +/*#__PURE__*/ +function () { + function AppOptions() { + _classCallCheck(this, AppOptions); + + throw new Error('Cannot initialize AppOptions.'); + } + + _createClass(AppOptions, null, [{ + key: "get", + value: function get(name) { + var userOption = userOptions[name]; + + if (userOption !== undefined) { + return userOption; + } + + var defaultOption = defaultOptions[name]; + + if (defaultOption !== undefined) { + return defaultOption.compatibility || defaultOption.value; + } + + return undefined; + } + }, { + key: "getAll", + value: function getAll() { + var kind = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + var options = Object.create(null); + + for (var name in defaultOptions) { + var defaultOption = defaultOptions[name]; + + if (kind && kind !== defaultOption.kind) { + continue; + } + + var userOption = userOptions[name]; + options[name] = userOption !== undefined ? userOption : defaultOption.compatibility || defaultOption.value; + } + + return options; + } + }, { + key: "set", + value: function set(name, value) { + userOptions[name] = value; + } + }, { + key: "remove", + value: function remove(name) { + delete userOptions[name]; + } + }]); + + return AppOptions; +}(); + +exports.AppOptions = AppOptions; + +/***/ }), +/* 13 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +var compatibilityParams = Object.create(null); +{ + var userAgent = typeof navigator !== 'undefined' && navigator.userAgent || ''; + var isAndroid = /Android/.test(userAgent); + var isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent); + + (function checkCanvasSizeLimitation() { + if (isIOS || isAndroid) { + compatibilityParams.maxCanvasPixels = 5242880; + } + })(); +} +exports.viewerCompatibilityParams = Object.freeze(compatibilityParams); + +/***/ }), +/* 14 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.OverlayManager = void 0; + +var _regenerator = _interopRequireDefault(__webpack_require__(2)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } + +function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var OverlayManager = +/*#__PURE__*/ +function () { + function OverlayManager() { + _classCallCheck(this, OverlayManager); + + this._overlays = {}; + this._active = null; + this._keyDownBound = this._keyDown.bind(this); + } + + _createClass(OverlayManager, [{ + key: "register", + value: function () { + var _register = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee(name, element) { + var callerCloseMethod, + canForceClose, + container, + _args = arguments; + return _regenerator.default.wrap(function _callee$(_context) { + while (1) { + switch (_context.prev = _context.next) { + case 0: + callerCloseMethod = _args.length > 2 && _args[2] !== undefined ? _args[2] : null; + canForceClose = _args.length > 3 && _args[3] !== undefined ? _args[3] : false; + + if (!(!name || !element || !(container = element.parentNode))) { + _context.next = 6; + break; + } + + throw new Error('Not enough parameters.'); + + case 6: + if (!this._overlays[name]) { + _context.next = 8; + break; + } + + throw new Error('The overlay is already registered.'); + + case 8: + this._overlays[name] = { + element: element, + container: container, + callerCloseMethod: callerCloseMethod, + canForceClose: canForceClose + }; + + case 9: + case "end": + return _context.stop(); + } + } + }, _callee, this); + })); + + function register(_x, _x2) { + return _register.apply(this, arguments); + } + + return register; + }() + }, { + key: "unregister", + value: function () { + var _unregister = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee2(name) { + return _regenerator.default.wrap(function _callee2$(_context2) { + while (1) { + switch (_context2.prev = _context2.next) { + case 0: + if (this._overlays[name]) { + _context2.next = 4; + break; + } + + throw new Error('The overlay does not exist.'); + + case 4: + if (!(this._active === name)) { + _context2.next = 6; + break; + } + + throw new Error('The overlay cannot be removed while it is active.'); + + case 6: + delete this._overlays[name]; + + case 7: + case "end": + return _context2.stop(); + } + } + }, _callee2, this); + })); + + function unregister(_x3) { + return _unregister.apply(this, arguments); + } + + return unregister; + }() + }, { + key: "open", + value: function () { + var _open = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee3(name) { + return _regenerator.default.wrap(function _callee3$(_context3) { + while (1) { + switch (_context3.prev = _context3.next) { + case 0: + if (this._overlays[name]) { + _context3.next = 4; + break; + } + + throw new Error('The overlay does not exist.'); + + case 4: + if (!this._active) { + _context3.next = 14; + break; + } + + if (!this._overlays[name].canForceClose) { + _context3.next = 9; + break; + } + + this._closeThroughCaller(); + + _context3.next = 14; + break; + + case 9: + if (!(this._active === name)) { + _context3.next = 13; + break; + } + + throw new Error('The overlay is already active.'); + + case 13: + throw new Error('Another overlay is currently active.'); + + case 14: + this._active = name; + + this._overlays[this._active].element.classList.remove('hidden'); + + this._overlays[this._active].container.classList.remove('hidden'); + + window.addEventListener('keydown', this._keyDownBound); + + case 18: + case "end": + return _context3.stop(); + } + } + }, _callee3, this); + })); + + function open(_x4) { + return _open.apply(this, arguments); + } + + return open; + }() + }, { + key: "close", + value: function () { + var _close = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee4(name) { + return _regenerator.default.wrap(function _callee4$(_context4) { + while (1) { + switch (_context4.prev = _context4.next) { + case 0: + if (this._overlays[name]) { + _context4.next = 4; + break; + } + + throw new Error('The overlay does not exist.'); + + case 4: + if (this._active) { + _context4.next = 8; + break; + } + + throw new Error('The overlay is currently not active.'); + + case 8: + if (!(this._active !== name)) { + _context4.next = 10; + break; + } + + throw new Error('Another overlay is currently active.'); + + case 10: + this._overlays[this._active].container.classList.add('hidden'); + + this._overlays[this._active].element.classList.add('hidden'); + + this._active = null; + window.removeEventListener('keydown', this._keyDownBound); + + case 14: + case "end": + return _context4.stop(); + } + } + }, _callee4, this); + })); + + function close(_x5) { + return _close.apply(this, arguments); + } + + return close; + }() + }, { + key: "_keyDown", + value: function _keyDown(evt) { + if (this._active && evt.keyCode === 27) { + this._closeThroughCaller(); + + evt.preventDefault(); + } + } + }, { + key: "_closeThroughCaller", + value: function _closeThroughCaller() { + if (this._overlays[this._active].callerCloseMethod) { + this._overlays[this._active].callerCloseMethod(); + } + + if (this._active) { + this.close(this._active); + } + } + }, { + key: "active", + get: function get() { + return this._active; + } + }]); + + return OverlayManager; +}(); + +exports.OverlayManager = OverlayManager; + +/***/ }), +/* 15 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PasswordPrompt = void 0; + +var _ui_utils = __webpack_require__(6); + +var _pdfjsLib = __webpack_require__(7); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var PasswordPrompt = +/*#__PURE__*/ +function () { + function PasswordPrompt(options, overlayManager) { + var _this = this; + + var l10n = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : _ui_utils.NullL10n; + + _classCallCheck(this, PasswordPrompt); + + this.overlayName = options.overlayName; + this.container = options.container; + this.label = options.label; + this.input = options.input; + this.submitButton = options.submitButton; + this.cancelButton = options.cancelButton; + this.overlayManager = overlayManager; + this.l10n = l10n; + this.updateCallback = null; + this.reason = null; + this.submitButton.addEventListener('click', this.verify.bind(this)); + this.cancelButton.addEventListener('click', this.close.bind(this)); + this.input.addEventListener('keydown', function (e) { + if (e.keyCode === 13) { + _this.verify(); + } + }); + this.overlayManager.register(this.overlayName, this.container, this.close.bind(this), true); + } + + _createClass(PasswordPrompt, [{ + key: "open", + value: function open() { + var _this2 = this; + + this.overlayManager.open(this.overlayName).then(function () { + _this2.input.focus(); + + var promptString; + + if (_this2.reason === _pdfjsLib.PasswordResponses.INCORRECT_PASSWORD) { + promptString = _this2.l10n.get('password_invalid', null, 'Invalid password. Please try again.'); + } else { + promptString = _this2.l10n.get('password_label', null, 'Enter the password to open this PDF file.'); + } + + promptString.then(function (msg) { + _this2.label.textContent = msg; + }); + }); + } + }, { + key: "close", + value: function close() { + var _this3 = this; + + this.overlayManager.close(this.overlayName).then(function () { + _this3.input.value = ''; + }); + } + }, { + key: "verify", + value: function verify() { + var password = this.input.value; + + if (password && password.length > 0) { + this.close(); + return this.updateCallback(password); + } + } + }, { + key: "setUpdateCallback", + value: function setUpdateCallback(updateCallback, reason) { + this.updateCallback = updateCallback; + this.reason = reason; + } + }]); + + return PasswordPrompt; +}(); + +exports.PasswordPrompt = PasswordPrompt; + +/***/ }), +/* 16 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFAttachmentViewer = void 0; + +var _pdfjsLib = __webpack_require__(7); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var PDFAttachmentViewer = +/*#__PURE__*/ +function () { + function PDFAttachmentViewer(_ref) { + var container = _ref.container, + eventBus = _ref.eventBus, + downloadManager = _ref.downloadManager; + + _classCallCheck(this, PDFAttachmentViewer); + + this.container = container; + this.eventBus = eventBus; + this.downloadManager = downloadManager; + this.reset(); + this.eventBus.on('fileattachmentannotation', this._appendAttachment.bind(this)); + } + + _createClass(PDFAttachmentViewer, [{ + key: "reset", + value: function reset() { + var keepRenderedCapability = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + this.attachments = null; + this.container.textContent = ''; + + if (!keepRenderedCapability) { + this._renderedCapability = (0, _pdfjsLib.createPromiseCapability)(); + } + } + }, { + key: "_dispatchEvent", + value: function _dispatchEvent(attachmentsCount) { + this._renderedCapability.resolve(); + + this.eventBus.dispatch('attachmentsloaded', { + source: this, + attachmentsCount: attachmentsCount + }); + } + }, { + key: "_bindPdfLink", + value: function _bindPdfLink(button, content, filename) { + if (this.downloadManager.disableCreateObjectURL) { + throw new Error('bindPdfLink: Unsupported "disableCreateObjectURL" value.'); + } + + var blobUrl; + + button.onclick = function () { + if (!blobUrl) { + blobUrl = (0, _pdfjsLib.createObjectURL)(content, 'application/pdf'); + } + + var viewerUrl; + viewerUrl = '?file=' + encodeURIComponent(blobUrl + '#' + filename); + window.open(viewerUrl); + return false; + }; + } + }, { + key: "_bindLink", + value: function _bindLink(button, content, filename) { + var _this = this; + + button.onclick = function () { + _this.downloadManager.downloadData(content, filename, ''); + + return false; + }; + } + }, { + key: "render", + value: function render(_ref2) { + var attachments = _ref2.attachments, + _ref2$keepRenderedCap = _ref2.keepRenderedCapability, + keepRenderedCapability = _ref2$keepRenderedCap === void 0 ? false : _ref2$keepRenderedCap; + var attachmentsCount = 0; + + if (this.attachments) { + this.reset(keepRenderedCapability === true); + } + + this.attachments = attachments || null; + + if (!attachments) { + this._dispatchEvent(attachmentsCount); + + return; + } + + var names = Object.keys(attachments).sort(function (a, b) { + return a.toLowerCase().localeCompare(b.toLowerCase()); + }); + attachmentsCount = names.length; + + for (var i = 0; i < attachmentsCount; i++) { + var item = attachments[names[i]]; + var filename = (0, _pdfjsLib.removeNullCharacters)((0, _pdfjsLib.getFilenameFromUrl)(item.filename)); + var div = document.createElement('div'); + div.className = 'attachmentsItem'; + var button = document.createElement('button'); + button.textContent = filename; + + if (/\.pdf$/i.test(filename) && !this.downloadManager.disableCreateObjectURL) { + this._bindPdfLink(button, item.content, filename); + } else { + this._bindLink(button, item.content, filename); + } + + div.appendChild(button); + this.container.appendChild(div); + } + + this._dispatchEvent(attachmentsCount); + } + }, { + key: "_appendAttachment", + value: function _appendAttachment(_ref3) { + var _this2 = this; + + var id = _ref3.id, + filename = _ref3.filename, + content = _ref3.content; + + this._renderedCapability.promise.then(function () { + var attachments = _this2.attachments; + + if (!attachments) { + attachments = Object.create(null); + } else { + for (var name in attachments) { + if (id === name) { + return; + } + } + } + + attachments[id] = { + filename: filename, + content: content + }; + + _this2.render({ + attachments: attachments, + keepRenderedCapability: true + }); + }); + } + }]); + + return PDFAttachmentViewer; +}(); + +exports.PDFAttachmentViewer = PDFAttachmentViewer; + +/***/ }), +/* 17 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFDocumentProperties = void 0; + +var _ui_utils = __webpack_require__(6); + +var _pdfjsLib = __webpack_require__(7); + +function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); } + +function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } + +function _iterableToArrayLimit(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } + +function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var DEFAULT_FIELD_CONTENT = '-'; +var NON_METRIC_LOCALES = ['en-us', 'en-lr', 'my']; +var US_PAGE_NAMES = { + '8.5x11': 'Letter', + '8.5x14': 'Legal' +}; +var METRIC_PAGE_NAMES = { + '297x420': 'A3', + '210x297': 'A4' +}; + +function getPageName(size, isPortrait, pageNames) { + var width = isPortrait ? size.width : size.height; + var height = isPortrait ? size.height : size.width; + return pageNames["".concat(width, "x").concat(height)]; +} + +var PDFDocumentProperties = +/*#__PURE__*/ +function () { + function PDFDocumentProperties(_ref, overlayManager, eventBus) { + var _this = this; + + var overlayName = _ref.overlayName, + fields = _ref.fields, + container = _ref.container, + closeButton = _ref.closeButton; + var l10n = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : _ui_utils.NullL10n; + + _classCallCheck(this, PDFDocumentProperties); + + this.overlayName = overlayName; + this.fields = fields; + this.container = container; + this.overlayManager = overlayManager; + this.l10n = l10n; + + this._reset(); + + if (closeButton) { + closeButton.addEventListener('click', this.close.bind(this)); + } + + this.overlayManager.register(this.overlayName, this.container, this.close.bind(this)); + + if (eventBus) { + eventBus.on('pagechanging', function (evt) { + _this._currentPageNumber = evt.pageNumber; + }); + eventBus.on('rotationchanging', function (evt) { + _this._pagesRotation = evt.pagesRotation; + }); + } + + this._isNonMetricLocale = true; + l10n.getLanguage().then(function (locale) { + _this._isNonMetricLocale = NON_METRIC_LOCALES.includes(locale); + }); + } + + _createClass(PDFDocumentProperties, [{ + key: "open", + value: function open() { + var _this2 = this; + + var freezeFieldData = function freezeFieldData(data) { + Object.defineProperty(_this2, 'fieldData', { + value: Object.freeze(data), + writable: false, + enumerable: true, + configurable: true + }); + }; + + Promise.all([this.overlayManager.open(this.overlayName), this._dataAvailableCapability.promise]).then(function () { + var currentPageNumber = _this2._currentPageNumber; + var pagesRotation = _this2._pagesRotation; + + if (_this2.fieldData && currentPageNumber === _this2.fieldData['_currentPageNumber'] && pagesRotation === _this2.fieldData['_pagesRotation']) { + _this2._updateUI(); + + return; + } + + _this2.pdfDocument.getMetadata().then(function (_ref2) { + var info = _ref2.info, + metadata = _ref2.metadata, + contentDispositionFilename = _ref2.contentDispositionFilename; + return Promise.all([info, metadata, contentDispositionFilename || (0, _ui_utils.getPDFFileNameFromURL)(_this2.url || ''), _this2._parseFileSize(_this2.maybeFileSize), _this2._parseDate(info.CreationDate), _this2._parseDate(info.ModDate), _this2.pdfDocument.getPage(currentPageNumber).then(function (pdfPage) { + return _this2._parsePageSize((0, _ui_utils.getPageSizeInches)(pdfPage), pagesRotation); + }), _this2._parseLinearization(info.IsLinearized)]); + }).then(function (_ref3) { + var _ref4 = _slicedToArray(_ref3, 8), + info = _ref4[0], + metadata = _ref4[1], + fileName = _ref4[2], + fileSize = _ref4[3], + creationDate = _ref4[4], + modDate = _ref4[5], + pageSize = _ref4[6], + isLinearized = _ref4[7]; + + freezeFieldData({ + 'fileName': fileName, + 'fileSize': fileSize, + 'title': info.Title, + 'author': info.Author, + 'subject': info.Subject, + 'keywords': info.Keywords, + 'creationDate': creationDate, + 'modificationDate': modDate, + 'creator': info.Creator, + 'producer': info.Producer, + 'version': info.PDFFormatVersion, + 'pageCount': _this2.pdfDocument.numPages, + 'pageSize': pageSize, + 'linearized': isLinearized, + '_currentPageNumber': currentPageNumber, + '_pagesRotation': pagesRotation + }); + + _this2._updateUI(); + + return _this2.pdfDocument.getDownloadInfo(); + }).then(function (_ref5) { + var length = _ref5.length; + _this2.maybeFileSize = length; + return _this2._parseFileSize(length); + }).then(function (fileSize) { + if (fileSize === _this2.fieldData['fileSize']) { + return; + } + + var data = Object.assign(Object.create(null), _this2.fieldData); + data['fileSize'] = fileSize; + freezeFieldData(data); + + _this2._updateUI(); + }); + }); + } + }, { + key: "close", + value: function close() { + this.overlayManager.close(this.overlayName); + } + }, { + key: "setDocument", + value: function setDocument(pdfDocument) { + var url = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; + + if (this.pdfDocument) { + this._reset(); + + this._updateUI(true); + } + + if (!pdfDocument) { + return; + } + + this.pdfDocument = pdfDocument; + this.url = url; + + this._dataAvailableCapability.resolve(); + } + }, { + key: "setFileSize", + value: function setFileSize(fileSize) { + if (Number.isInteger(fileSize) && fileSize > 0) { + this.maybeFileSize = fileSize; + } + } + }, { + key: "_reset", + value: function _reset() { + this.pdfDocument = null; + this.url = null; + this.maybeFileSize = 0; + delete this.fieldData; + this._dataAvailableCapability = (0, _pdfjsLib.createPromiseCapability)(); + this._currentPageNumber = 1; + this._pagesRotation = 0; + } + }, { + key: "_updateUI", + value: function _updateUI() { + var reset = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + + if (reset || !this.fieldData) { + for (var id in this.fields) { + this.fields[id].textContent = DEFAULT_FIELD_CONTENT; + } + + return; + } + + if (this.overlayManager.active !== this.overlayName) { + return; + } + + for (var _id in this.fields) { + var content = this.fieldData[_id]; + this.fields[_id].textContent = content || content === 0 ? content : DEFAULT_FIELD_CONTENT; + } + } + }, { + key: "_parseFileSize", + value: function _parseFileSize() { + var fileSize = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + var kb = fileSize / 1024; + + if (!kb) { + return Promise.resolve(undefined); + } else if (kb < 1024) { + return this.l10n.get('document_properties_kb', { + size_kb: (+kb.toPrecision(3)).toLocaleString(), + size_b: fileSize.toLocaleString() + }, '{{size_kb}} KB ({{size_b}} bytes)'); + } + + return this.l10n.get('document_properties_mb', { + size_mb: (+(kb / 1024).toPrecision(3)).toLocaleString(), + size_b: fileSize.toLocaleString() + }, '{{size_mb}} MB ({{size_b}} bytes)'); + } + }, { + key: "_parsePageSize", + value: function _parsePageSize(pageSizeInches, pagesRotation) { + var _this3 = this; + + if (!pageSizeInches) { + return Promise.resolve(undefined); + } + + if (pagesRotation % 180 !== 0) { + pageSizeInches = { + width: pageSizeInches.height, + height: pageSizeInches.width + }; + } + + var isPortrait = (0, _ui_utils.isPortraitOrientation)(pageSizeInches); + var sizeInches = { + width: Math.round(pageSizeInches.width * 100) / 100, + height: Math.round(pageSizeInches.height * 100) / 100 + }; + var sizeMillimeters = { + width: Math.round(pageSizeInches.width * 25.4 * 10) / 10, + height: Math.round(pageSizeInches.height * 25.4 * 10) / 10 + }; + var pageName = null; + var name = getPageName(sizeInches, isPortrait, US_PAGE_NAMES) || getPageName(sizeMillimeters, isPortrait, METRIC_PAGE_NAMES); + + if (!name && !(Number.isInteger(sizeMillimeters.width) && Number.isInteger(sizeMillimeters.height))) { + var exactMillimeters = { + width: pageSizeInches.width * 25.4, + height: pageSizeInches.height * 25.4 + }; + var intMillimeters = { + width: Math.round(sizeMillimeters.width), + height: Math.round(sizeMillimeters.height) + }; + + if (Math.abs(exactMillimeters.width - intMillimeters.width) < 0.1 && Math.abs(exactMillimeters.height - intMillimeters.height) < 0.1) { + name = getPageName(intMillimeters, isPortrait, METRIC_PAGE_NAMES); + + if (name) { + sizeInches = { + width: Math.round(intMillimeters.width / 25.4 * 100) / 100, + height: Math.round(intMillimeters.height / 25.4 * 100) / 100 + }; + sizeMillimeters = intMillimeters; + } + } + } + + if (name) { + pageName = this.l10n.get('document_properties_page_size_name_' + name.toLowerCase(), null, name); + } + + return Promise.all([this._isNonMetricLocale ? sizeInches : sizeMillimeters, this.l10n.get('document_properties_page_size_unit_' + (this._isNonMetricLocale ? 'inches' : 'millimeters'), null, this._isNonMetricLocale ? 'in' : 'mm'), pageName, this.l10n.get('document_properties_page_size_orientation_' + (isPortrait ? 'portrait' : 'landscape'), null, isPortrait ? 'portrait' : 'landscape')]).then(function (_ref6) { + var _ref7 = _slicedToArray(_ref6, 4), + _ref7$ = _ref7[0], + width = _ref7$.width, + height = _ref7$.height, + unit = _ref7[1], + name = _ref7[2], + orientation = _ref7[3]; + + return _this3.l10n.get('document_properties_page_size_dimension_' + (name ? 'name_' : '') + 'string', { + width: width.toLocaleString(), + height: height.toLocaleString(), + unit: unit, + name: name, + orientation: orientation + }, '{{width}} × {{height}} {{unit}} (' + (name ? '{{name}}, ' : '') + '{{orientation}})'); + }); + } + }, { + key: "_parseDate", + value: function _parseDate(inputDate) { + if (!inputDate) { + return; + } + + var dateToParse = inputDate; + + if (dateToParse.substring(0, 2) === 'D:') { + dateToParse = dateToParse.substring(2); + } + + var year = parseInt(dateToParse.substring(0, 4), 10); + var month = parseInt(dateToParse.substring(4, 6), 10) - 1; + var day = parseInt(dateToParse.substring(6, 8), 10); + var hours = parseInt(dateToParse.substring(8, 10), 10); + var minutes = parseInt(dateToParse.substring(10, 12), 10); + var seconds = parseInt(dateToParse.substring(12, 14), 10); + var utRel = dateToParse.substring(14, 15); + var offsetHours = parseInt(dateToParse.substring(15, 17), 10); + var offsetMinutes = parseInt(dateToParse.substring(18, 20), 10); + + if (utRel === '-') { + hours += offsetHours; + minutes += offsetMinutes; + } else if (utRel === '+') { + hours -= offsetHours; + minutes -= offsetMinutes; + } + + var date = new Date(Date.UTC(year, month, day, hours, minutes, seconds)); + var dateString = date.toLocaleDateString(); + var timeString = date.toLocaleTimeString(); + return this.l10n.get('document_properties_date_string', { + date: dateString, + time: timeString + }, '{{date}}, {{time}}'); + } + }, { + key: "_parseLinearization", + value: function _parseLinearization(isLinearized) { + return this.l10n.get('document_properties_linearized_' + (isLinearized ? 'yes' : 'no'), null, isLinearized ? 'Yes' : 'No'); + } + }]); + + return PDFDocumentProperties; +}(); + +exports.PDFDocumentProperties = PDFDocumentProperties; + +/***/ }), +/* 18 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFFindBar = void 0; + +var _ui_utils = __webpack_require__(6); + +var _pdf_find_controller = __webpack_require__(19); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var MATCHES_COUNT_LIMIT = 1000; + +var PDFFindBar = +/*#__PURE__*/ +function () { + function PDFFindBar(options) { + var _this = this; + + var eventBus = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : (0, _ui_utils.getGlobalEventBus)(); + var l10n = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : _ui_utils.NullL10n; + + _classCallCheck(this, PDFFindBar); + + this.opened = false; + this.bar = options.bar || null; + this.toggleButton = options.toggleButton || null; + this.findField = options.findField || null; + this.highlightAll = options.highlightAllCheckbox || null; + this.caseSensitive = options.caseSensitiveCheckbox || null; + this.entireWord = options.entireWordCheckbox || null; + this.findMsg = options.findMsg || null; + this.findResultsCount = options.findResultsCount || null; + this.findPreviousButton = options.findPreviousButton || null; + this.findNextButton = options.findNextButton || null; + this.eventBus = eventBus; + this.l10n = l10n; + this.toggleButton.addEventListener('click', function () { + _this.toggle(); + }); + this.findField.addEventListener('input', function () { + _this.dispatchEvent(''); + }); + this.bar.addEventListener('keydown', function (e) { + switch (e.keyCode) { + case 13: + if (e.target === _this.findField) { + _this.dispatchEvent('again', e.shiftKey); + } + + break; + + case 27: + _this.close(); + + break; + } + }); + this.findPreviousButton.addEventListener('click', function () { + _this.dispatchEvent('again', true); + }); + this.findNextButton.addEventListener('click', function () { + _this.dispatchEvent('again', false); + }); + this.highlightAll.addEventListener('click', function () { + _this.dispatchEvent('highlightallchange'); + }); + this.caseSensitive.addEventListener('click', function () { + _this.dispatchEvent('casesensitivitychange'); + }); + this.entireWord.addEventListener('click', function () { + _this.dispatchEvent('entirewordchange'); + }); + this.eventBus.on('resize', this._adjustWidth.bind(this)); + } + + _createClass(PDFFindBar, [{ + key: "reset", + value: function reset() { + this.updateUIState(); + } + }, { + key: "dispatchEvent", + value: function dispatchEvent(type, findPrev) { + this.eventBus.dispatch('find', { + source: this, + type: type, + query: this.findField.value, + phraseSearch: true, + caseSensitive: this.caseSensitive.checked, + entireWord: this.entireWord.checked, + highlightAll: this.highlightAll.checked, + findPrevious: findPrev + }); + } + }, { + key: "updateUIState", + value: function updateUIState(state, previous, matchesCount) { + var _this2 = this; + + var notFound = false; + var findMsg = ''; + var status = ''; + + switch (state) { + case _pdf_find_controller.FindState.FOUND: + break; + + case _pdf_find_controller.FindState.PENDING: + status = 'pending'; + break; + + case _pdf_find_controller.FindState.NOT_FOUND: + findMsg = this.l10n.get('find_not_found', null, 'Phrase not found'); + notFound = true; + break; + + case _pdf_find_controller.FindState.WRAPPED: + if (previous) { + findMsg = this.l10n.get('find_reached_top', null, 'Reached top of document, continued from bottom'); + } else { + findMsg = this.l10n.get('find_reached_bottom', null, 'Reached end of document, continued from top'); + } + + break; + } + + this.findField.classList.toggle('notFound', notFound); + this.findField.setAttribute('data-status', status); + Promise.resolve(findMsg).then(function (msg) { + _this2.findMsg.textContent = msg; + + _this2._adjustWidth(); + }); + this.updateResultsCount(matchesCount); + } + }, { + key: "updateResultsCount", + value: function updateResultsCount() { + var _this3 = this; + + var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, + _ref$current = _ref.current, + current = _ref$current === void 0 ? 0 : _ref$current, + _ref$total = _ref.total, + total = _ref$total === void 0 ? 0 : _ref$total; + + if (!this.findResultsCount) { + return; + } + + var matchesCountMsg = '', + limit = MATCHES_COUNT_LIMIT; + + if (total > 0) { + if (total > limit) { + matchesCountMsg = this.l10n.get('find_match_count_limit', { + limit: limit + }, 'More than {{limit}} match' + (limit !== 1 ? 'es' : '')); + } else { + matchesCountMsg = this.l10n.get('find_match_count', { + current: current, + total: total + }, '{{current}} of {{total}} match' + (total !== 1 ? 'es' : '')); + } + } + + Promise.resolve(matchesCountMsg).then(function (msg) { + _this3.findResultsCount.textContent = msg; + + _this3.findResultsCount.classList.toggle('hidden', !total); + + _this3._adjustWidth(); + }); + } + }, { + key: "open", + value: function open() { + if (!this.opened) { + this.opened = true; + this.toggleButton.classList.add('toggled'); + this.bar.classList.remove('hidden'); + } + + this.findField.select(); + this.findField.focus(); + + this._adjustWidth(); + } + }, { + key: "close", + value: function close() { + if (!this.opened) { + return; + } + + this.opened = false; + this.toggleButton.classList.remove('toggled'); + this.bar.classList.add('hidden'); + this.eventBus.dispatch('findbarclose', { + source: this + }); + } + }, { + key: "toggle", + value: function toggle() { + if (this.opened) { + this.close(); + } else { + this.open(); + } + } + }, { + key: "_adjustWidth", + value: function _adjustWidth() { + if (!this.opened) { + return; + } + + this.bar.classList.remove('wrapContainers'); + var findbarHeight = this.bar.clientHeight; + var inputContainerHeight = this.bar.firstElementChild.clientHeight; + + if (findbarHeight > inputContainerHeight) { + this.bar.classList.add('wrapContainers'); + } + } + }]); + + return PDFFindBar; +}(); + +exports.PDFFindBar = PDFFindBar; + +/***/ }), +/* 19 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFFindController = exports.FindState = void 0; + +var _ui_utils = __webpack_require__(6); + +var _pdfjsLib = __webpack_require__(7); + +var _pdf_find_utils = __webpack_require__(20); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var FindState = { + FOUND: 0, + NOT_FOUND: 1, + WRAPPED: 2, + PENDING: 3 +}; +exports.FindState = FindState; +var FIND_TIMEOUT = 250; +var MATCH_SCROLL_OFFSET_TOP = -50; +var MATCH_SCROLL_OFFSET_LEFT = -400; +var CHARACTERS_TO_NORMALIZE = { + "\u2018": '\'', + "\u2019": '\'', + "\u201A": '\'', + "\u201B": '\'', + "\u201C": '"', + "\u201D": '"', + "\u201E": '"', + "\u201F": '"', + "\xBC": '1/4', + "\xBD": '1/2', + "\xBE": '3/4' +}; +var normalizationRegex = null; + +function normalize(text) { + if (!normalizationRegex) { + var replace = Object.keys(CHARACTERS_TO_NORMALIZE).join(''); + normalizationRegex = new RegExp("[".concat(replace, "]"), 'g'); + } + + return text.replace(normalizationRegex, function (ch) { + return CHARACTERS_TO_NORMALIZE[ch]; + }); +} + +var PDFFindController = +/*#__PURE__*/ +function () { + function PDFFindController(_ref) { + var linkService = _ref.linkService, + _ref$eventBus = _ref.eventBus, + eventBus = _ref$eventBus === void 0 ? (0, _ui_utils.getGlobalEventBus)() : _ref$eventBus; + + _classCallCheck(this, PDFFindController); + + this._linkService = linkService; + this._eventBus = eventBus; + + this._reset(); + + eventBus.on('findbarclose', this._onFindBarClose.bind(this)); + } + + _createClass(PDFFindController, [{ + key: "setDocument", + value: function setDocument(pdfDocument) { + if (this._pdfDocument) { + this._reset(); + } + + if (!pdfDocument) { + return; + } + + this._pdfDocument = pdfDocument; + + this._firstPageCapability.resolve(); + } + }, { + key: "executeCommand", + value: function executeCommand(cmd, state) { + var _this = this; + + if (!state) { + return; + } + + var pdfDocument = this._pdfDocument; + + if (this._state === null || this._shouldDirtyMatch(cmd, state)) { + this._dirtyMatch = true; + } + + this._state = state; + + if (cmd !== 'findhighlightallchange') { + this._updateUIState(FindState.PENDING); + } + + this._firstPageCapability.promise.then(function () { + if (!_this._pdfDocument || pdfDocument && _this._pdfDocument !== pdfDocument) { + return; + } + + _this._extractText(); + + var findbarClosed = !_this._highlightMatches; + var pendingTimeout = !!_this._findTimeout; + + if (_this._findTimeout) { + clearTimeout(_this._findTimeout); + _this._findTimeout = null; + } + + if (cmd === 'find') { + _this._findTimeout = setTimeout(function () { + _this._nextMatch(); + + _this._findTimeout = null; + }, FIND_TIMEOUT); + } else if (_this._dirtyMatch) { + _this._nextMatch(); + } else if (cmd === 'findagain') { + _this._nextMatch(); + + if (findbarClosed && _this._state.highlightAll) { + _this._updateAllPages(); + } + } else if (cmd === 'findhighlightallchange') { + if (pendingTimeout) { + _this._nextMatch(); + } else { + _this._highlightMatches = true; + } + + _this._updateAllPages(); + } else { + _this._nextMatch(); + } + }); + } + }, { + key: "scrollMatchIntoView", + value: function scrollMatchIntoView(_ref2) { + var _ref2$element = _ref2.element, + element = _ref2$element === void 0 ? null : _ref2$element, + _ref2$pageIndex = _ref2.pageIndex, + pageIndex = _ref2$pageIndex === void 0 ? -1 : _ref2$pageIndex, + _ref2$matchIndex = _ref2.matchIndex, + matchIndex = _ref2$matchIndex === void 0 ? -1 : _ref2$matchIndex; + + if (!this._scrollMatches || !element) { + return; + } else if (matchIndex === -1 || matchIndex !== this._selected.matchIdx) { + return; + } else if (pageIndex === -1 || pageIndex !== this._selected.pageIdx) { + return; + } + + this._scrollMatches = false; + var spot = { + top: MATCH_SCROLL_OFFSET_TOP, + left: MATCH_SCROLL_OFFSET_LEFT + }; + (0, _ui_utils.scrollIntoView)(element, spot, true); + } + }, { + key: "_reset", + value: function _reset() { + this._highlightMatches = false; + this._scrollMatches = false; + this._pdfDocument = null; + this._pageMatches = []; + this._pageMatchesLength = []; + this._state = null; + this._selected = { + pageIdx: -1, + matchIdx: -1 + }; + this._offset = { + pageIdx: null, + matchIdx: null, + wrapped: false + }; + this._extractTextPromises = []; + this._pageContents = []; + this._matchesCountTotal = 0; + this._pagesToSearch = null; + this._pendingFindMatches = Object.create(null); + this._resumePageIdx = null; + this._dirtyMatch = false; + clearTimeout(this._findTimeout); + this._findTimeout = null; + this._firstPageCapability = (0, _pdfjsLib.createPromiseCapability)(); + } + }, { + key: "_shouldDirtyMatch", + value: function _shouldDirtyMatch(cmd, state) { + if (state.query !== this._state.query) { + return true; + } + + switch (cmd) { + case 'findagain': + var pageNumber = this._selected.pageIdx + 1; + var linkService = this._linkService; + + if (pageNumber >= 1 && pageNumber <= linkService.pagesCount && linkService.page !== pageNumber && linkService.isPageVisible && !linkService.isPageVisible(pageNumber)) { + break; + } + + return false; + + case 'findhighlightallchange': + return false; + } + + return true; + } + }, { + key: "_prepareMatches", + value: function _prepareMatches(matchesWithLength, matches, matchesLength) { + function isSubTerm(matchesWithLength, currentIndex) { + var currentElem = matchesWithLength[currentIndex]; + var nextElem = matchesWithLength[currentIndex + 1]; + + if (currentIndex < matchesWithLength.length - 1 && currentElem.match === nextElem.match) { + currentElem.skipped = true; + return true; + } + + for (var i = currentIndex - 1; i >= 0; i--) { + var prevElem = matchesWithLength[i]; + + if (prevElem.skipped) { + continue; + } + + if (prevElem.match + prevElem.matchLength < currentElem.match) { + break; + } + + if (prevElem.match + prevElem.matchLength >= currentElem.match + currentElem.matchLength) { + currentElem.skipped = true; + return true; + } + } + + return false; + } + + matchesWithLength.sort(function (a, b) { + return a.match === b.match ? a.matchLength - b.matchLength : a.match - b.match; + }); + + for (var i = 0, len = matchesWithLength.length; i < len; i++) { + if (isSubTerm(matchesWithLength, i)) { + continue; + } + + matches.push(matchesWithLength[i].match); + matchesLength.push(matchesWithLength[i].matchLength); + } + } + }, { + key: "_isEntireWord", + value: function _isEntireWord(content, startIdx, length) { + if (startIdx > 0) { + var first = content.charCodeAt(startIdx); + var limit = content.charCodeAt(startIdx - 1); + + if ((0, _pdf_find_utils.getCharacterType)(first) === (0, _pdf_find_utils.getCharacterType)(limit)) { + return false; + } + } + + var endIdx = startIdx + length - 1; + + if (endIdx < content.length - 1) { + var last = content.charCodeAt(endIdx); + + var _limit = content.charCodeAt(endIdx + 1); + + if ((0, _pdf_find_utils.getCharacterType)(last) === (0, _pdf_find_utils.getCharacterType)(_limit)) { + return false; + } + } + + return true; + } + }, { + key: "_calculatePhraseMatch", + value: function _calculatePhraseMatch(query, pageIndex, pageContent, entireWord) { + var matches = []; + var queryLen = query.length; + var matchIdx = -queryLen; + + while (true) { + matchIdx = pageContent.indexOf(query, matchIdx + queryLen); + + if (matchIdx === -1) { + break; + } + + if (entireWord && !this._isEntireWord(pageContent, matchIdx, queryLen)) { + continue; + } + + matches.push(matchIdx); + } + + this._pageMatches[pageIndex] = matches; + } + }, { + key: "_calculateWordMatch", + value: function _calculateWordMatch(query, pageIndex, pageContent, entireWord) { + var matchesWithLength = []; + var queryArray = query.match(/\S+/g); + + for (var i = 0, len = queryArray.length; i < len; i++) { + var subquery = queryArray[i]; + var subqueryLen = subquery.length; + var matchIdx = -subqueryLen; + + while (true) { + matchIdx = pageContent.indexOf(subquery, matchIdx + subqueryLen); + + if (matchIdx === -1) { + break; + } + + if (entireWord && !this._isEntireWord(pageContent, matchIdx, subqueryLen)) { + continue; + } + + matchesWithLength.push({ + match: matchIdx, + matchLength: subqueryLen, + skipped: false + }); + } + } + + this._pageMatchesLength[pageIndex] = []; + this._pageMatches[pageIndex] = []; + + this._prepareMatches(matchesWithLength, this._pageMatches[pageIndex], this._pageMatchesLength[pageIndex]); + } + }, { + key: "_calculateMatch", + value: function _calculateMatch(pageIndex) { + var pageContent = this._pageContents[pageIndex]; + var query = this._query; + var _this$_state = this._state, + caseSensitive = _this$_state.caseSensitive, + entireWord = _this$_state.entireWord, + phraseSearch = _this$_state.phraseSearch; + + if (query.length === 0) { + return; + } + + if (!caseSensitive) { + pageContent = pageContent.toLowerCase(); + query = query.toLowerCase(); + } + + if (phraseSearch) { + this._calculatePhraseMatch(query, pageIndex, pageContent, entireWord); + } else { + this._calculateWordMatch(query, pageIndex, pageContent, entireWord); + } + + if (this._state.highlightAll) { + this._updatePage(pageIndex); + } + + if (this._resumePageIdx === pageIndex) { + this._resumePageIdx = null; + + this._nextPageMatch(); + } + + var pageMatchesCount = this._pageMatches[pageIndex].length; + + if (pageMatchesCount > 0) { + this._matchesCountTotal += pageMatchesCount; + + this._updateUIResultsCount(); + } + } + }, { + key: "_extractText", + value: function _extractText() { + var _this2 = this; + + if (this._extractTextPromises.length > 0) { + return; + } + + var promise = Promise.resolve(); + + var _loop = function _loop(i, ii) { + var extractTextCapability = (0, _pdfjsLib.createPromiseCapability)(); + _this2._extractTextPromises[i] = extractTextCapability.promise; + promise = promise.then(function () { + return _this2._pdfDocument.getPage(i + 1).then(function (pdfPage) { + return pdfPage.getTextContent({ + normalizeWhitespace: true + }); + }).then(function (textContent) { + var textItems = textContent.items; + var strBuf = []; + + for (var j = 0, jj = textItems.length; j < jj; j++) { + strBuf.push(textItems[j].str); + } + + _this2._pageContents[i] = normalize(strBuf.join('')); + extractTextCapability.resolve(i); + }, function (reason) { + console.error("Unable to get text content for page ".concat(i + 1), reason); + _this2._pageContents[i] = ''; + extractTextCapability.resolve(i); + }); + }); + }; + + for (var i = 0, ii = this._linkService.pagesCount; i < ii; i++) { + _loop(i, ii); + } + } + }, { + key: "_updatePage", + value: function _updatePage(index) { + if (this._scrollMatches && this._selected.pageIdx === index) { + this._linkService.page = index + 1; + } + + this._eventBus.dispatch('updatetextlayermatches', { + source: this, + pageIndex: index + }); + } + }, { + key: "_updateAllPages", + value: function _updateAllPages() { + this._eventBus.dispatch('updatetextlayermatches', { + source: this, + pageIndex: -1 + }); + } + }, { + key: "_nextMatch", + value: function _nextMatch() { + var _this3 = this; + + var previous = this._state.findPrevious; + var currentPageIndex = this._linkService.page - 1; + var numPages = this._linkService.pagesCount; + this._highlightMatches = true; + + if (this._dirtyMatch) { + this._dirtyMatch = false; + this._selected.pageIdx = this._selected.matchIdx = -1; + this._offset.pageIdx = currentPageIndex; + this._offset.matchIdx = null; + this._offset.wrapped = false; + this._resumePageIdx = null; + this._pageMatches.length = 0; + this._pageMatchesLength.length = 0; + this._matchesCountTotal = 0; + + this._updateAllPages(); + + for (var i = 0; i < numPages; i++) { + if (this._pendingFindMatches[i] === true) { + continue; + } + + this._pendingFindMatches[i] = true; + + this._extractTextPromises[i].then(function (pageIdx) { + delete _this3._pendingFindMatches[pageIdx]; + + _this3._calculateMatch(pageIdx); + }); + } + } + + if (this._query === '') { + this._updateUIState(FindState.FOUND); + + return; + } + + if (this._resumePageIdx) { + return; + } + + var offset = this._offset; + this._pagesToSearch = numPages; + + if (offset.matchIdx !== null) { + var numPageMatches = this._pageMatches[offset.pageIdx].length; + + if (!previous && offset.matchIdx + 1 < numPageMatches || previous && offset.matchIdx > 0) { + offset.matchIdx = previous ? offset.matchIdx - 1 : offset.matchIdx + 1; + + this._updateMatch(true); + + return; + } + + this._advanceOffsetPage(previous); + } + + this._nextPageMatch(); + } + }, { + key: "_matchesReady", + value: function _matchesReady(matches) { + var offset = this._offset; + var numMatches = matches.length; + var previous = this._state.findPrevious; + + if (numMatches) { + offset.matchIdx = previous ? numMatches - 1 : 0; + + this._updateMatch(true); + + return true; + } + + this._advanceOffsetPage(previous); + + if (offset.wrapped) { + offset.matchIdx = null; + + if (this._pagesToSearch < 0) { + this._updateMatch(false); + + return true; + } + } + + return false; + } + }, { + key: "_nextPageMatch", + value: function _nextPageMatch() { + if (this._resumePageIdx !== null) { + console.error('There can only be one pending page.'); + } + + var matches = null; + + do { + var pageIdx = this._offset.pageIdx; + matches = this._pageMatches[pageIdx]; + + if (!matches) { + this._resumePageIdx = pageIdx; + break; + } + } while (!this._matchesReady(matches)); + } + }, { + key: "_advanceOffsetPage", + value: function _advanceOffsetPage(previous) { + var offset = this._offset; + var numPages = this._linkService.pagesCount; + offset.pageIdx = previous ? offset.pageIdx - 1 : offset.pageIdx + 1; + offset.matchIdx = null; + this._pagesToSearch--; + + if (offset.pageIdx >= numPages || offset.pageIdx < 0) { + offset.pageIdx = previous ? numPages - 1 : 0; + offset.wrapped = true; + } + } + }, { + key: "_updateMatch", + value: function _updateMatch() { + var found = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + var state = FindState.NOT_FOUND; + var wrapped = this._offset.wrapped; + this._offset.wrapped = false; + + if (found) { + var previousPage = this._selected.pageIdx; + this._selected.pageIdx = this._offset.pageIdx; + this._selected.matchIdx = this._offset.matchIdx; + state = wrapped ? FindState.WRAPPED : FindState.FOUND; + + if (previousPage !== -1 && previousPage !== this._selected.pageIdx) { + this._updatePage(previousPage); + } + } + + this._updateUIState(state, this._state.findPrevious); + + if (this._selected.pageIdx !== -1) { + this._scrollMatches = true; + + this._updatePage(this._selected.pageIdx); + } + } + }, { + key: "_onFindBarClose", + value: function _onFindBarClose(evt) { + var _this4 = this; + + var pdfDocument = this._pdfDocument; + + this._firstPageCapability.promise.then(function () { + if (!_this4._pdfDocument || pdfDocument && _this4._pdfDocument !== pdfDocument) { + return; + } + + if (_this4._findTimeout) { + clearTimeout(_this4._findTimeout); + _this4._findTimeout = null; + } + + if (_this4._resumePageIdx) { + _this4._resumePageIdx = null; + _this4._dirtyMatch = true; + } + + _this4._updateUIState(FindState.FOUND); + + _this4._highlightMatches = false; + + _this4._updateAllPages(); + }); + } + }, { + key: "_requestMatchesCount", + value: function _requestMatchesCount() { + var _this$_selected = this._selected, + pageIdx = _this$_selected.pageIdx, + matchIdx = _this$_selected.matchIdx; + var current = 0, + total = this._matchesCountTotal; + + if (matchIdx !== -1) { + for (var i = 0; i < pageIdx; i++) { + current += this._pageMatches[i] && this._pageMatches[i].length || 0; + } + + current += matchIdx + 1; + } + + if (current < 1 || current > total) { + current = total = 0; + } + + return { + current: current, + total: total + }; + } + }, { + key: "_updateUIResultsCount", + value: function _updateUIResultsCount() { + this._eventBus.dispatch('updatefindmatchescount', { + source: this, + matchesCount: this._requestMatchesCount() + }); + } + }, { + key: "_updateUIState", + value: function _updateUIState(state, previous) { + this._eventBus.dispatch('updatefindcontrolstate', { + source: this, + state: state, + previous: previous, + matchesCount: this._requestMatchesCount() + }); + } + }, { + key: "highlightMatches", + get: function get() { + return this._highlightMatches; + } + }, { + key: "pageMatches", + get: function get() { + return this._pageMatches; + } + }, { + key: "pageMatchesLength", + get: function get() { + return this._pageMatchesLength; + } + }, { + key: "selected", + get: function get() { + return this._selected; + } + }, { + key: "state", + get: function get() { + return this._state; + } + }, { + key: "_query", + get: function get() { + if (this._state.query !== this._rawQuery) { + this._rawQuery = this._state.query; + this._normalizedQuery = normalize(this._state.query); + } + + return this._normalizedQuery; + } + }]); + + return PDFFindController; +}(); + +exports.PDFFindController = PDFFindController; + +/***/ }), +/* 20 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.getCharacterType = getCharacterType; +exports.CharacterType = void 0; +var CharacterType = { + SPACE: 0, + ALPHA_LETTER: 1, + PUNCT: 2, + HAN_LETTER: 3, + KATAKANA_LETTER: 4, + HIRAGANA_LETTER: 5, + HALFWIDTH_KATAKANA_LETTER: 6, + THAI_LETTER: 7 +}; +exports.CharacterType = CharacterType; + +function isAlphabeticalScript(charCode) { + return charCode < 0x2E80; +} + +function isAscii(charCode) { + return (charCode & 0xFF80) === 0; +} + +function isAsciiAlpha(charCode) { + return charCode >= 0x61 && charCode <= 0x7A || charCode >= 0x41 && charCode <= 0x5A; +} + +function isAsciiDigit(charCode) { + return charCode >= 0x30 && charCode <= 0x39; +} + +function isAsciiSpace(charCode) { + return charCode === 0x20 || charCode === 0x09 || charCode === 0x0D || charCode === 0x0A; +} + +function isHan(charCode) { + return charCode >= 0x3400 && charCode <= 0x9FFF || charCode >= 0xF900 && charCode <= 0xFAFF; +} + +function isKatakana(charCode) { + return charCode >= 0x30A0 && charCode <= 0x30FF; +} + +function isHiragana(charCode) { + return charCode >= 0x3040 && charCode <= 0x309F; +} + +function isHalfwidthKatakana(charCode) { + return charCode >= 0xFF60 && charCode <= 0xFF9F; +} + +function isThai(charCode) { + return (charCode & 0xFF80) === 0x0E00; +} + +function getCharacterType(charCode) { + if (isAlphabeticalScript(charCode)) { + if (isAscii(charCode)) { + if (isAsciiSpace(charCode)) { + return CharacterType.SPACE; + } else if (isAsciiAlpha(charCode) || isAsciiDigit(charCode) || charCode === 0x5F) { + return CharacterType.ALPHA_LETTER; + } + + return CharacterType.PUNCT; + } else if (isThai(charCode)) { + return CharacterType.THAI_LETTER; + } else if (charCode === 0xA0) { + return CharacterType.SPACE; + } + + return CharacterType.ALPHA_LETTER; + } + + if (isHan(charCode)) { + return CharacterType.HAN_LETTER; + } else if (isKatakana(charCode)) { + return CharacterType.KATAKANA_LETTER; + } else if (isHiragana(charCode)) { + return CharacterType.HIRAGANA_LETTER; + } else if (isHalfwidthKatakana(charCode)) { + return CharacterType.HALFWIDTH_KATAKANA_LETTER; + } + + return CharacterType.ALPHA_LETTER; +} + +/***/ }), +/* 21 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.isDestHashesEqual = isDestHashesEqual; +exports.isDestArraysEqual = isDestArraysEqual; +exports.PDFHistory = void 0; + +var _ui_utils = __webpack_require__(6); + +function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); } + +function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } + +function _iterableToArrayLimit(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } + +function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var HASH_CHANGE_TIMEOUT = 1000; +var POSITION_UPDATED_THRESHOLD = 50; +var UPDATE_VIEWAREA_TIMEOUT = 1000; + +function getCurrentHash() { + return document.location.hash; +} + +function parseCurrentHash(linkService) { + var hash = unescape(getCurrentHash()).substring(1); + var params = (0, _ui_utils.parseQueryString)(hash); + var page = params.page | 0; + + if (!(Number.isInteger(page) && page > 0 && page <= linkService.pagesCount)) { + page = null; + } + + return { + hash: hash, + page: page, + rotation: linkService.rotation + }; +} + +var PDFHistory = +/*#__PURE__*/ +function () { + function PDFHistory(_ref) { + var _this = this; + + var linkService = _ref.linkService, + eventBus = _ref.eventBus; + + _classCallCheck(this, PDFHistory); + + this.linkService = linkService; + this.eventBus = eventBus || (0, _ui_utils.getGlobalEventBus)(); + this.initialized = false; + this.initialBookmark = null; + this.initialRotation = null; + this._boundEvents = Object.create(null); + this._isViewerInPresentationMode = false; + this._isPagesLoaded = false; + this.eventBus.on('presentationmodechanged', function (evt) { + _this._isViewerInPresentationMode = evt.active || evt.switchInProgress; + }); + this.eventBus.on('pagesloaded', function (evt) { + _this._isPagesLoaded = !!evt.pagesCount; + }); + } + + _createClass(PDFHistory, [{ + key: "initialize", + value: function initialize(_ref2) { + var fingerprint = _ref2.fingerprint, + _ref2$resetHistory = _ref2.resetHistory, + resetHistory = _ref2$resetHistory === void 0 ? false : _ref2$resetHistory, + _ref2$updateUrl = _ref2.updateUrl, + updateUrl = _ref2$updateUrl === void 0 ? false : _ref2$updateUrl; + + if (!fingerprint || typeof fingerprint !== 'string') { + console.error('PDFHistory.initialize: The "fingerprint" must be a non-empty string.'); + return; + } + + var reInitialized = this.initialized && this.fingerprint !== fingerprint; + this.fingerprint = fingerprint; + this._updateUrl = updateUrl === true; + + if (!this.initialized) { + this._bindEvents(); + } + + var state = window.history.state; + this.initialized = true; + this.initialBookmark = null; + this.initialRotation = null; + this._popStateInProgress = false; + this._blockHashChange = 0; + this._currentHash = getCurrentHash(); + this._numPositionUpdates = 0; + this._uid = this._maxUid = 0; + this._destination = null; + this._position = null; + + if (!this._isValidState(state, true) || resetHistory) { + var _parseCurrentHash = parseCurrentHash(this.linkService), + hash = _parseCurrentHash.hash, + page = _parseCurrentHash.page, + rotation = _parseCurrentHash.rotation; + + if (!hash || reInitialized || resetHistory) { + this._pushOrReplaceState(null, true); + + return; + } + + this._pushOrReplaceState({ + hash: hash, + page: page, + rotation: rotation + }, true); + + return; + } + + var destination = state.destination; + + this._updateInternalState(destination, state.uid, true); + + if (this._uid > this._maxUid) { + this._maxUid = this._uid; + } + + if (destination.rotation !== undefined) { + this.initialRotation = destination.rotation; + } + + if (destination.dest) { + this.initialBookmark = JSON.stringify(destination.dest); + this._destination.page = null; + } else if (destination.hash) { + this.initialBookmark = destination.hash; + } else if (destination.page) { + this.initialBookmark = "page=".concat(destination.page); + } + } + }, { + key: "push", + value: function push(_ref3) { + var _this2 = this; + + var _ref3$namedDest = _ref3.namedDest, + namedDest = _ref3$namedDest === void 0 ? null : _ref3$namedDest, + explicitDest = _ref3.explicitDest, + pageNumber = _ref3.pageNumber; + + if (!this.initialized) { + return; + } + + if (namedDest && typeof namedDest !== 'string') { + console.error('PDFHistory.push: ' + "\"".concat(namedDest, "\" is not a valid namedDest parameter.")); + return; + } else if (!Array.isArray(explicitDest)) { + console.error('PDFHistory.push: ' + "\"".concat(explicitDest, "\" is not a valid explicitDest parameter.")); + return; + } else if (!(Number.isInteger(pageNumber) && pageNumber > 0 && pageNumber <= this.linkService.pagesCount)) { + if (pageNumber !== null || this._destination) { + console.error('PDFHistory.push: ' + "\"".concat(pageNumber, "\" is not a valid pageNumber parameter.")); + return; + } + } + + var hash = namedDest || JSON.stringify(explicitDest); + + if (!hash) { + return; + } + + var forceReplace = false; + + if (this._destination && (isDestHashesEqual(this._destination.hash, hash) || isDestArraysEqual(this._destination.dest, explicitDest))) { + if (this._destination.page) { + return; + } + + forceReplace = true; + } + + if (this._popStateInProgress && !forceReplace) { + return; + } + + this._pushOrReplaceState({ + dest: explicitDest, + hash: hash, + page: pageNumber, + rotation: this.linkService.rotation + }, forceReplace); + + if (!this._popStateInProgress) { + this._popStateInProgress = true; + Promise.resolve().then(function () { + _this2._popStateInProgress = false; + }); + } + } + }, { + key: "pushCurrentPosition", + value: function pushCurrentPosition() { + if (!this.initialized || this._popStateInProgress) { + return; + } + + this._tryPushCurrentPosition(); + } + }, { + key: "back", + value: function back() { + if (!this.initialized || this._popStateInProgress) { + return; + } + + var state = window.history.state; + + if (this._isValidState(state) && state.uid > 0) { + window.history.back(); + } + } + }, { + key: "forward", + value: function forward() { + if (!this.initialized || this._popStateInProgress) { + return; + } + + var state = window.history.state; + + if (this._isValidState(state) && state.uid < this._maxUid) { + window.history.forward(); + } + } + }, { + key: "_pushOrReplaceState", + value: function _pushOrReplaceState(destination) { + var forceReplace = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + var shouldReplace = forceReplace || !this._destination; + var newState = { + fingerprint: this.fingerprint, + uid: shouldReplace ? this._uid : this._uid + 1, + destination: destination + }; + + this._updateInternalState(destination, newState.uid); + + var newUrl; + + if (this._updateUrl && destination && destination.hash) { + var baseUrl = document.location.href.split('#')[0]; + + if (!baseUrl.startsWith('file://')) { + newUrl = "".concat(baseUrl, "#").concat(destination.hash); + } + } + + if (shouldReplace) { + if (newUrl) { + window.history.replaceState(newState, '', newUrl); + } else { + window.history.replaceState(newState, ''); + } + } else { + this._maxUid = this._uid; + + if (newUrl) { + window.history.pushState(newState, '', newUrl); + } else { + window.history.pushState(newState, ''); + } + } + } + }, { + key: "_tryPushCurrentPosition", + value: function _tryPushCurrentPosition() { + var temporary = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + + if (!this._position) { + return; + } + + var position = this._position; + + if (temporary) { + position = Object.assign(Object.create(null), this._position); + position.temporary = true; + } + + if (!this._destination) { + this._pushOrReplaceState(position); + + return; + } + + if (this._destination.temporary) { + this._pushOrReplaceState(position, true); + + return; + } + + if (this._destination.hash === position.hash) { + return; + } + + if (!this._destination.page && (POSITION_UPDATED_THRESHOLD <= 0 || this._numPositionUpdates <= POSITION_UPDATED_THRESHOLD)) { + return; + } + + var forceReplace = false; + + if (this._destination.page >= position.first && this._destination.page <= position.page) { + if (this._destination.dest || !this._destination.first) { + return; + } + + forceReplace = true; + } + + this._pushOrReplaceState(position, forceReplace); + } + }, { + key: "_isValidState", + value: function _isValidState(state) { + var checkReload = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + + if (!state) { + return false; + } + + if (state.fingerprint !== this.fingerprint) { + if (checkReload) { + if (typeof state.fingerprint !== 'string' || state.fingerprint.length !== this.fingerprint.length) { + return false; + } + + var _performance$getEntri = performance.getEntriesByType('navigation'), + _performance$getEntri2 = _slicedToArray(_performance$getEntri, 1), + perfEntry = _performance$getEntri2[0]; + + if (!perfEntry || perfEntry.type !== 'reload') { + return false; + } + } else { + return false; + } + } + + if (!Number.isInteger(state.uid) || state.uid < 0) { + return false; + } + + if (state.destination === null || _typeof(state.destination) !== 'object') { + return false; + } + + return true; + } + }, { + key: "_updateInternalState", + value: function _updateInternalState(destination, uid) { + var removeTemporary = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + + if (this._updateViewareaTimeout) { + clearTimeout(this._updateViewareaTimeout); + this._updateViewareaTimeout = null; + } + + if (removeTemporary && destination && destination.temporary) { + delete destination.temporary; + } + + this._destination = destination; + this._uid = uid; + this._numPositionUpdates = 0; + } + }, { + key: "_updateViewarea", + value: function _updateViewarea(_ref4) { + var _this3 = this; + + var location = _ref4.location; + + if (this._updateViewareaTimeout) { + clearTimeout(this._updateViewareaTimeout); + this._updateViewareaTimeout = null; + } + + this._position = { + hash: this._isViewerInPresentationMode ? "page=".concat(location.pageNumber) : location.pdfOpenParams.substring(1), + page: this.linkService.page, + first: location.pageNumber, + rotation: location.rotation + }; + + if (this._popStateInProgress) { + return; + } + + if (POSITION_UPDATED_THRESHOLD > 0 && this._isPagesLoaded && this._destination && !this._destination.page) { + this._numPositionUpdates++; + } + + if (UPDATE_VIEWAREA_TIMEOUT > 0) { + this._updateViewareaTimeout = setTimeout(function () { + if (!_this3._popStateInProgress) { + _this3._tryPushCurrentPosition(true); + } + + _this3._updateViewareaTimeout = null; + }, UPDATE_VIEWAREA_TIMEOUT); + } + } + }, { + key: "_popState", + value: function _popState(_ref5) { + var _this4 = this; + + var state = _ref5.state; + var newHash = getCurrentHash(), + hashChanged = this._currentHash !== newHash; + this._currentHash = newHash; + + if (!state || false) { + this._uid++; + + var _parseCurrentHash2 = parseCurrentHash(this.linkService), + hash = _parseCurrentHash2.hash, + page = _parseCurrentHash2.page, + rotation = _parseCurrentHash2.rotation; + + this._pushOrReplaceState({ + hash: hash, + page: page, + rotation: rotation + }, true); + + return; + } + + if (!this._isValidState(state)) { + return; + } + + this._popStateInProgress = true; + + if (hashChanged) { + this._blockHashChange++; + (0, _ui_utils.waitOnEventOrTimeout)({ + target: window, + name: 'hashchange', + delay: HASH_CHANGE_TIMEOUT + }).then(function () { + _this4._blockHashChange--; + }); + } + + var destination = state.destination; + + this._updateInternalState(destination, state.uid, true); + + if (this._uid > this._maxUid) { + this._maxUid = this._uid; + } + + if ((0, _ui_utils.isValidRotation)(destination.rotation)) { + this.linkService.rotation = destination.rotation; + } + + if (destination.dest) { + this.linkService.navigateTo(destination.dest); + } else if (destination.hash) { + this.linkService.setHash(destination.hash); + } else if (destination.page) { + this.linkService.page = destination.page; + } + + Promise.resolve().then(function () { + _this4._popStateInProgress = false; + }); + } + }, { + key: "_bindEvents", + value: function _bindEvents() { + var _this5 = this; + + var _boundEvents = this._boundEvents, + eventBus = this.eventBus; + _boundEvents.updateViewarea = this._updateViewarea.bind(this); + _boundEvents.popState = this._popState.bind(this); + + _boundEvents.pageHide = function (evt) { + if (!_this5._destination || _this5._destination.temporary) { + _this5._tryPushCurrentPosition(); + } + }; + + eventBus.on('updateviewarea', _boundEvents.updateViewarea); + window.addEventListener('popstate', _boundEvents.popState); + window.addEventListener('pagehide', _boundEvents.pageHide); + } + }, { + key: "popStateInProgress", + get: function get() { + return this.initialized && (this._popStateInProgress || this._blockHashChange > 0); + } + }]); + + return PDFHistory; +}(); + +exports.PDFHistory = PDFHistory; + +function isDestHashesEqual(destHash, pushHash) { + if (typeof destHash !== 'string' || typeof pushHash !== 'string') { + return false; + } + + if (destHash === pushHash) { + return true; + } + + var _parseQueryString = (0, _ui_utils.parseQueryString)(destHash), + nameddest = _parseQueryString.nameddest; + + if (nameddest === pushHash) { + return true; + } + + return false; +} + +function isDestArraysEqual(firstDest, secondDest) { + function isEntryEqual(first, second) { + if (_typeof(first) !== _typeof(second)) { + return false; + } + + if (Array.isArray(first) || Array.isArray(second)) { + return false; + } + + if (first !== null && _typeof(first) === 'object' && second !== null) { + if (Object.keys(first).length !== Object.keys(second).length) { + return false; + } + + for (var key in first) { + if (!isEntryEqual(first[key], second[key])) { + return false; + } + } + + return true; + } + + return first === second || Number.isNaN(first) && Number.isNaN(second); + } + + if (!(Array.isArray(firstDest) && Array.isArray(secondDest))) { + return false; + } + + if (firstDest.length !== secondDest.length) { + return false; + } + + for (var i = 0, ii = firstDest.length; i < ii; i++) { + if (!isEntryEqual(firstDest[i], secondDest[i])) { + return false; + } + } + + return true; +} + +/***/ }), +/* 22 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SimpleLinkService = exports.PDFLinkService = void 0; + +var _ui_utils = __webpack_require__(6); + +function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var PDFLinkService = +/*#__PURE__*/ +function () { + function PDFLinkService() { + var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, + eventBus = _ref.eventBus, + _ref$externalLinkTarg = _ref.externalLinkTarget, + externalLinkTarget = _ref$externalLinkTarg === void 0 ? null : _ref$externalLinkTarg, + _ref$externalLinkRel = _ref.externalLinkRel, + externalLinkRel = _ref$externalLinkRel === void 0 ? null : _ref$externalLinkRel; + + _classCallCheck(this, PDFLinkService); + + this.eventBus = eventBus || (0, _ui_utils.getGlobalEventBus)(); + this.externalLinkTarget = externalLinkTarget; + this.externalLinkRel = externalLinkRel; + this.baseUrl = null; + this.pdfDocument = null; + this.pdfViewer = null; + this.pdfHistory = null; + this._pagesRefCache = null; + } + + _createClass(PDFLinkService, [{ + key: "setDocument", + value: function setDocument(pdfDocument) { + var baseUrl = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; + this.baseUrl = baseUrl; + this.pdfDocument = pdfDocument; + this._pagesRefCache = Object.create(null); + } + }, { + key: "setViewer", + value: function setViewer(pdfViewer) { + this.pdfViewer = pdfViewer; + } + }, { + key: "setHistory", + value: function setHistory(pdfHistory) { + this.pdfHistory = pdfHistory; + } + }, { + key: "navigateTo", + value: function navigateTo(dest) { + var _this = this; + + var goToDestination = function goToDestination(_ref2) { + var namedDest = _ref2.namedDest, + explicitDest = _ref2.explicitDest; + var destRef = explicitDest[0], + pageNumber; + + if (destRef instanceof Object) { + pageNumber = _this._cachedPageNumber(destRef); + + if (pageNumber === null) { + _this.pdfDocument.getPageIndex(destRef).then(function (pageIndex) { + _this.cachePageRef(pageIndex + 1, destRef); + + goToDestination({ + namedDest: namedDest, + explicitDest: explicitDest + }); + }).catch(function () { + console.error("PDFLinkService.navigateTo: \"".concat(destRef, "\" is not ") + "a valid page reference, for dest=\"".concat(dest, "\".")); + }); + + return; + } + } else if (Number.isInteger(destRef)) { + pageNumber = destRef + 1; + } else { + console.error("PDFLinkService.navigateTo: \"".concat(destRef, "\" is not ") + "a valid destination reference, for dest=\"".concat(dest, "\".")); + return; + } + + if (!pageNumber || pageNumber < 1 || pageNumber > _this.pagesCount) { + console.error("PDFLinkService.navigateTo: \"".concat(pageNumber, "\" is not ") + "a valid page number, for dest=\"".concat(dest, "\".")); + return; + } + + if (_this.pdfHistory) { + _this.pdfHistory.pushCurrentPosition(); + + _this.pdfHistory.push({ + namedDest: namedDest, + explicitDest: explicitDest, + pageNumber: pageNumber + }); + } + + _this.pdfViewer.scrollPageIntoView({ + pageNumber: pageNumber, + destArray: explicitDest + }); + }; + + new Promise(function (resolve, reject) { + if (typeof dest === 'string') { + _this.pdfDocument.getDestination(dest).then(function (destArray) { + resolve({ + namedDest: dest, + explicitDest: destArray + }); + }); + + return; + } + + resolve({ + namedDest: '', + explicitDest: dest + }); + }).then(function (data) { + if (!Array.isArray(data.explicitDest)) { + console.error("PDFLinkService.navigateTo: \"".concat(data.explicitDest, "\" is") + " not a valid destination array, for dest=\"".concat(dest, "\".")); + return; + } + + goToDestination(data); + }); + } + }, { + key: "getDestinationHash", + value: function getDestinationHash(dest) { + if (typeof dest === 'string') { + return this.getAnchorUrl('#' + escape(dest)); + } + + if (Array.isArray(dest)) { + var str = JSON.stringify(dest); + return this.getAnchorUrl('#' + escape(str)); + } + + return this.getAnchorUrl(''); + } + }, { + key: "getAnchorUrl", + value: function getAnchorUrl(anchor) { + return (this.baseUrl || '') + anchor; + } + }, { + key: "setHash", + value: function setHash(hash) { + var pageNumber, dest; + + if (hash.includes('=')) { + var params = (0, _ui_utils.parseQueryString)(hash); + + if ('search' in params) { + this.eventBus.dispatch('findfromurlhash', { + source: this, + query: params['search'].replace(/"/g, ''), + phraseSearch: params['phrase'] === 'true' + }); + } + + if ('nameddest' in params) { + this.navigateTo(params.nameddest); + return; + } + + if ('page' in params) { + pageNumber = params.page | 0 || 1; + } + + if ('zoom' in params) { + var zoomArgs = params.zoom.split(','); + var zoomArg = zoomArgs[0]; + var zoomArgNumber = parseFloat(zoomArg); + + if (!zoomArg.includes('Fit')) { + dest = [null, { + name: 'XYZ' + }, zoomArgs.length > 1 ? zoomArgs[1] | 0 : null, zoomArgs.length > 2 ? zoomArgs[2] | 0 : null, zoomArgNumber ? zoomArgNumber / 100 : zoomArg]; + } else { + if (zoomArg === 'Fit' || zoomArg === 'FitB') { + dest = [null, { + name: zoomArg + }]; + } else if (zoomArg === 'FitH' || zoomArg === 'FitBH' || zoomArg === 'FitV' || zoomArg === 'FitBV') { + dest = [null, { + name: zoomArg + }, zoomArgs.length > 1 ? zoomArgs[1] | 0 : null]; + } else if (zoomArg === 'FitR') { + if (zoomArgs.length !== 5) { + console.error('PDFLinkService.setHash: Not enough parameters for "FitR".'); + } else { + dest = [null, { + name: zoomArg + }, zoomArgs[1] | 0, zoomArgs[2] | 0, zoomArgs[3] | 0, zoomArgs[4] | 0]; + } + } else { + console.error("PDFLinkService.setHash: \"".concat(zoomArg, "\" is not ") + 'a valid zoom value.'); + } + } + } + + if (dest) { + this.pdfViewer.scrollPageIntoView({ + pageNumber: pageNumber || this.page, + destArray: dest, + allowNegativeOffset: true + }); + } else if (pageNumber) { + this.page = pageNumber; + } + + if ('pagemode' in params) { + this.eventBus.dispatch('pagemode', { + source: this, + mode: params.pagemode + }); + } + } else { + dest = unescape(hash); + + try { + dest = JSON.parse(dest); + + if (!Array.isArray(dest)) { + dest = dest.toString(); + } + } catch (ex) {} + + if (typeof dest === 'string' || isValidExplicitDestination(dest)) { + this.navigateTo(dest); + return; + } + + console.error("PDFLinkService.setHash: \"".concat(unescape(hash), "\" is not ") + 'a valid destination.'); + } + } + }, { + key: "executeNamedAction", + value: function executeNamedAction(action) { + switch (action) { + case 'GoBack': + if (this.pdfHistory) { + this.pdfHistory.back(); + } + + break; + + case 'GoForward': + if (this.pdfHistory) { + this.pdfHistory.forward(); + } + + break; + + case 'NextPage': + if (this.page < this.pagesCount) { + this.page++; + } + + break; + + case 'PrevPage': + if (this.page > 1) { + this.page--; + } + + break; + + case 'LastPage': + this.page = this.pagesCount; + break; + + case 'FirstPage': + this.page = 1; + break; + + default: + break; + } + + this.eventBus.dispatch('namedaction', { + source: this, + action: action + }); + } + }, { + key: "cachePageRef", + value: function cachePageRef(pageNum, pageRef) { + if (!pageRef) { + return; + } + + var refStr = pageRef.num + ' ' + pageRef.gen + ' R'; + this._pagesRefCache[refStr] = pageNum; + } + }, { + key: "_cachedPageNumber", + value: function _cachedPageNumber(pageRef) { + var refStr = pageRef.num + ' ' + pageRef.gen + ' R'; + return this._pagesRefCache && this._pagesRefCache[refStr] || null; + } + }, { + key: "isPageVisible", + value: function isPageVisible(pageNumber) { + return this.pdfViewer.isPageVisible(pageNumber); + } + }, { + key: "pagesCount", + get: function get() { + return this.pdfDocument ? this.pdfDocument.numPages : 0; + } + }, { + key: "page", + get: function get() { + return this.pdfViewer.currentPageNumber; + }, + set: function set(value) { + this.pdfViewer.currentPageNumber = value; + } + }, { + key: "rotation", + get: function get() { + return this.pdfViewer.pagesRotation; + }, + set: function set(value) { + this.pdfViewer.pagesRotation = value; + } + }]); + + return PDFLinkService; +}(); + +exports.PDFLinkService = PDFLinkService; + +function isValidExplicitDestination(dest) { + if (!Array.isArray(dest)) { + return false; + } + + var destLength = dest.length, + allowNull = true; + + if (destLength < 2) { + return false; + } + + var page = dest[0]; + + if (!(_typeof(page) === 'object' && Number.isInteger(page.num) && Number.isInteger(page.gen)) && !(Number.isInteger(page) && page >= 0)) { + return false; + } + + var zoom = dest[1]; + + if (!(_typeof(zoom) === 'object' && typeof zoom.name === 'string')) { + return false; + } + + switch (zoom.name) { + case 'XYZ': + if (destLength !== 5) { + return false; + } + + break; + + case 'Fit': + case 'FitB': + return destLength === 2; + + case 'FitH': + case 'FitBH': + case 'FitV': + case 'FitBV': + if (destLength !== 3) { + return false; + } + + break; + + case 'FitR': + if (destLength !== 6) { + return false; + } + + allowNull = false; + break; + + default: + return false; + } + + for (var i = 2; i < destLength; i++) { + var param = dest[i]; + + if (!(typeof param === 'number' || allowNull && param === null)) { + return false; + } + } + + return true; +} + +var SimpleLinkService = +/*#__PURE__*/ +function () { + function SimpleLinkService() { + _classCallCheck(this, SimpleLinkService); + + this.externalLinkTarget = null; + this.externalLinkRel = null; + } + + _createClass(SimpleLinkService, [{ + key: "navigateTo", + value: function navigateTo(dest) {} + }, { + key: "getDestinationHash", + value: function getDestinationHash(dest) { + return '#'; + } + }, { + key: "getAnchorUrl", + value: function getAnchorUrl(hash) { + return '#'; + } + }, { + key: "setHash", + value: function setHash(hash) {} + }, { + key: "executeNamedAction", + value: function executeNamedAction(action) {} + }, { + key: "cachePageRef", + value: function cachePageRef(pageNum, pageRef) {} + }, { + key: "isPageVisible", + value: function isPageVisible(pageNumber) { + return true; + } + }, { + key: "pagesCount", + get: function get() { + return 0; + } + }, { + key: "page", + get: function get() { + return 0; + }, + set: function set(value) {} + }, { + key: "rotation", + get: function get() { + return 0; + }, + set: function set(value) {} + }]); + + return SimpleLinkService; +}(); + +exports.SimpleLinkService = SimpleLinkService; + +/***/ }), +/* 23 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFOutlineViewer = void 0; + +var _pdfjsLib = __webpack_require__(7); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var DEFAULT_TITLE = "\u2013"; + +var PDFOutlineViewer = +/*#__PURE__*/ +function () { + function PDFOutlineViewer(_ref) { + var container = _ref.container, + linkService = _ref.linkService, + eventBus = _ref.eventBus; + + _classCallCheck(this, PDFOutlineViewer); + + this.container = container; + this.linkService = linkService; + this.eventBus = eventBus; + this.reset(); + eventBus.on('toggleoutlinetree', this.toggleOutlineTree.bind(this)); + } + + _createClass(PDFOutlineViewer, [{ + key: "reset", + value: function reset() { + this.outline = null; + this.lastToggleIsShow = true; + this.container.textContent = ''; + this.container.classList.remove('outlineWithDeepNesting'); + } + }, { + key: "_dispatchEvent", + value: function _dispatchEvent(outlineCount) { + this.eventBus.dispatch('outlineloaded', { + source: this, + outlineCount: outlineCount + }); + } + }, { + key: "_bindLink", + value: function _bindLink(element, _ref2) { + var url = _ref2.url, + newWindow = _ref2.newWindow, + dest = _ref2.dest; + var linkService = this.linkService; + + if (url) { + (0, _pdfjsLib.addLinkAttributes)(element, { + url: url, + target: newWindow ? _pdfjsLib.LinkTarget.BLANK : linkService.externalLinkTarget, + rel: linkService.externalLinkRel + }); + return; + } + + element.href = linkService.getDestinationHash(dest); + + element.onclick = function () { + if (dest) { + linkService.navigateTo(dest); + } + + return false; + }; + } + }, { + key: "_setStyles", + value: function _setStyles(element, _ref3) { + var bold = _ref3.bold, + italic = _ref3.italic; + var styleStr = ''; + + if (bold) { + styleStr += 'font-weight: bold;'; + } + + if (italic) { + styleStr += 'font-style: italic;'; + } + + if (styleStr) { + element.setAttribute('style', styleStr); + } + } + }, { + key: "_addToggleButton", + value: function _addToggleButton(div) { + var _this = this; + + var toggler = document.createElement('div'); + toggler.className = 'outlineItemToggler'; + + toggler.onclick = function (evt) { + evt.stopPropagation(); + toggler.classList.toggle('outlineItemsHidden'); + + if (evt.shiftKey) { + var shouldShowAll = !toggler.classList.contains('outlineItemsHidden'); + + _this._toggleOutlineItem(div, shouldShowAll); + } + }; + + div.insertBefore(toggler, div.firstChild); + } + }, { + key: "_toggleOutlineItem", + value: function _toggleOutlineItem(root) { + var show = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + this.lastToggleIsShow = show; + var _iteratorNormalCompletion = true; + var _didIteratorError = false; + var _iteratorError = undefined; + + try { + for (var _iterator = root.querySelectorAll('.outlineItemToggler')[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { + var toggler = _step.value; + toggler.classList.toggle('outlineItemsHidden', !show); + } + } catch (err) { + _didIteratorError = true; + _iteratorError = err; + } finally { + try { + if (!_iteratorNormalCompletion && _iterator.return != null) { + _iterator.return(); + } + } finally { + if (_didIteratorError) { + throw _iteratorError; + } + } + } + } + }, { + key: "toggleOutlineTree", + value: function toggleOutlineTree() { + if (!this.outline) { + return; + } + + this._toggleOutlineItem(this.container, !this.lastToggleIsShow); + } + }, { + key: "render", + value: function render(_ref4) { + var outline = _ref4.outline; + var outlineCount = 0; + + if (this.outline) { + this.reset(); + } + + this.outline = outline || null; + + if (!outline) { + this._dispatchEvent(outlineCount); + + return; + } + + var fragment = document.createDocumentFragment(); + var queue = [{ + parent: fragment, + items: this.outline + }]; + var hasAnyNesting = false; + + while (queue.length > 0) { + var levelData = queue.shift(); + + for (var i = 0, len = levelData.items.length; i < len; i++) { + var item = levelData.items[i]; + var div = document.createElement('div'); + div.className = 'outlineItem'; + var element = document.createElement('a'); + + this._bindLink(element, item); + + this._setStyles(element, item); + + element.textContent = (0, _pdfjsLib.removeNullCharacters)(item.title) || DEFAULT_TITLE; + div.appendChild(element); + + if (item.items.length > 0) { + hasAnyNesting = true; + + this._addToggleButton(div); + + var itemsDiv = document.createElement('div'); + itemsDiv.className = 'outlineItems'; + div.appendChild(itemsDiv); + queue.push({ + parent: itemsDiv, + items: item.items + }); + } + + levelData.parent.appendChild(div); + outlineCount++; + } + } + + if (hasAnyNesting) { + this.container.classList.add('outlineWithDeepNesting'); + } + + this.container.appendChild(fragment); + + this._dispatchEvent(outlineCount); + } + }]); + + return PDFOutlineViewer; +}(); + +exports.PDFOutlineViewer = PDFOutlineViewer; + +/***/ }), +/* 24 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFPresentationMode = void 0; + +var _ui_utils = __webpack_require__(6); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var DELAY_BEFORE_RESETTING_SWITCH_IN_PROGRESS = 1500; +var DELAY_BEFORE_HIDING_CONTROLS = 3000; +var ACTIVE_SELECTOR = 'pdfPresentationMode'; +var CONTROLS_SELECTOR = 'pdfPresentationModeControls'; +var MOUSE_SCROLL_COOLDOWN_TIME = 50; +var PAGE_SWITCH_THRESHOLD = 0.1; +var SWIPE_MIN_DISTANCE_THRESHOLD = 50; +var SWIPE_ANGLE_THRESHOLD = Math.PI / 6; + +var PDFPresentationMode = +/*#__PURE__*/ +function () { + function PDFPresentationMode(_ref) { + var _this = this; + + var container = _ref.container, + _ref$viewer = _ref.viewer, + viewer = _ref$viewer === void 0 ? null : _ref$viewer, + pdfViewer = _ref.pdfViewer, + eventBus = _ref.eventBus, + _ref$contextMenuItems = _ref.contextMenuItems, + contextMenuItems = _ref$contextMenuItems === void 0 ? null : _ref$contextMenuItems; + + _classCallCheck(this, PDFPresentationMode); + + this.container = container; + this.viewer = viewer || container.firstElementChild; + this.pdfViewer = pdfViewer; + this.eventBus = eventBus; + this.active = false; + this.args = null; + this.contextMenuOpen = false; + this.mouseScrollTimeStamp = 0; + this.mouseScrollDelta = 0; + this.touchSwipeState = null; + + if (contextMenuItems) { + contextMenuItems.contextFirstPage.addEventListener('click', function () { + _this.contextMenuOpen = false; + + _this.eventBus.dispatch('firstpage', { + source: _this + }); + }); + contextMenuItems.contextLastPage.addEventListener('click', function () { + _this.contextMenuOpen = false; + + _this.eventBus.dispatch('lastpage', { + source: _this + }); + }); + contextMenuItems.contextPageRotateCw.addEventListener('click', function () { + _this.contextMenuOpen = false; + + _this.eventBus.dispatch('rotatecw', { + source: _this + }); + }); + contextMenuItems.contextPageRotateCcw.addEventListener('click', function () { + _this.contextMenuOpen = false; + + _this.eventBus.dispatch('rotateccw', { + source: _this + }); + }); + } + } + + _createClass(PDFPresentationMode, [{ + key: "request", + value: function request() { + if (this.switchInProgress || this.active || !this.viewer.hasChildNodes()) { + return false; + } + + this._addFullscreenChangeListeners(); + + this._setSwitchInProgress(); + + this._notifyStateChange(); + + if (this.container.requestFullscreen) { + this.container.requestFullscreen(); + } else if (this.container.mozRequestFullScreen) { + this.container.mozRequestFullScreen(); + } else if (this.container.webkitRequestFullscreen) { + this.container.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT); + } else if (this.container.msRequestFullscreen) { + this.container.msRequestFullscreen(); + } else { + return false; + } + + this.args = { + page: this.pdfViewer.currentPageNumber, + previousScale: this.pdfViewer.currentScaleValue + }; + return true; + } + }, { + key: "_mouseWheel", + value: function _mouseWheel(evt) { + if (!this.active) { + return; + } + + evt.preventDefault(); + var delta = (0, _ui_utils.normalizeWheelEventDelta)(evt); + var currentTime = new Date().getTime(); + var storedTime = this.mouseScrollTimeStamp; + + if (currentTime > storedTime && currentTime - storedTime < MOUSE_SCROLL_COOLDOWN_TIME) { + return; + } + + if (this.mouseScrollDelta > 0 && delta < 0 || this.mouseScrollDelta < 0 && delta > 0) { + this._resetMouseScrollState(); + } + + this.mouseScrollDelta += delta; + + if (Math.abs(this.mouseScrollDelta) >= PAGE_SWITCH_THRESHOLD) { + var totalDelta = this.mouseScrollDelta; + + this._resetMouseScrollState(); + + var success = totalDelta > 0 ? this._goToPreviousPage() : this._goToNextPage(); + + if (success) { + this.mouseScrollTimeStamp = currentTime; + } + } + } + }, { + key: "_goToPreviousPage", + value: function _goToPreviousPage() { + var page = this.pdfViewer.currentPageNumber; + + if (page <= 1) { + return false; + } + + this.pdfViewer.currentPageNumber = page - 1; + return true; + } + }, { + key: "_goToNextPage", + value: function _goToNextPage() { + var page = this.pdfViewer.currentPageNumber; + + if (page >= this.pdfViewer.pagesCount) { + return false; + } + + this.pdfViewer.currentPageNumber = page + 1; + return true; + } + }, { + key: "_notifyStateChange", + value: function _notifyStateChange() { + this.eventBus.dispatch('presentationmodechanged', { + source: this, + active: this.active, + switchInProgress: !!this.switchInProgress + }); + } + }, { + key: "_setSwitchInProgress", + value: function _setSwitchInProgress() { + var _this2 = this; + + if (this.switchInProgress) { + clearTimeout(this.switchInProgress); + } + + this.switchInProgress = setTimeout(function () { + _this2._removeFullscreenChangeListeners(); + + delete _this2.switchInProgress; + + _this2._notifyStateChange(); + }, DELAY_BEFORE_RESETTING_SWITCH_IN_PROGRESS); + } + }, { + key: "_resetSwitchInProgress", + value: function _resetSwitchInProgress() { + if (this.switchInProgress) { + clearTimeout(this.switchInProgress); + delete this.switchInProgress; + } + } + }, { + key: "_enter", + value: function _enter() { + var _this3 = this; + + this.active = true; + + this._resetSwitchInProgress(); + + this._notifyStateChange(); + + this.container.classList.add(ACTIVE_SELECTOR); + setTimeout(function () { + _this3.pdfViewer.currentPageNumber = _this3.args.page; + _this3.pdfViewer.currentScaleValue = 'page-fit'; + }, 0); + + this._addWindowListeners(); + + this._showControls(); + + this.contextMenuOpen = false; + this.container.setAttribute('contextmenu', 'viewerContextMenu'); + window.getSelection().removeAllRanges(); + } + }, { + key: "_exit", + value: function _exit() { + var _this4 = this; + + var page = this.pdfViewer.currentPageNumber; + this.container.classList.remove(ACTIVE_SELECTOR); + setTimeout(function () { + _this4.active = false; + + _this4._removeFullscreenChangeListeners(); + + _this4._notifyStateChange(); + + _this4.pdfViewer.currentScaleValue = _this4.args.previousScale; + _this4.pdfViewer.currentPageNumber = page; + _this4.args = null; + }, 0); + + this._removeWindowListeners(); + + this._hideControls(); + + this._resetMouseScrollState(); + + this.container.removeAttribute('contextmenu'); + this.contextMenuOpen = false; + } + }, { + key: "_mouseDown", + value: function _mouseDown(evt) { + if (this.contextMenuOpen) { + this.contextMenuOpen = false; + evt.preventDefault(); + return; + } + + if (evt.button === 0) { + var isInternalLink = evt.target.href && evt.target.classList.contains('internalLink'); + + if (!isInternalLink) { + evt.preventDefault(); + + if (evt.shiftKey) { + this._goToPreviousPage(); + } else { + this._goToNextPage(); + } + } + } + } + }, { + key: "_contextMenu", + value: function _contextMenu() { + this.contextMenuOpen = true; + } + }, { + key: "_showControls", + value: function _showControls() { + var _this5 = this; + + if (this.controlsTimeout) { + clearTimeout(this.controlsTimeout); + } else { + this.container.classList.add(CONTROLS_SELECTOR); + } + + this.controlsTimeout = setTimeout(function () { + _this5.container.classList.remove(CONTROLS_SELECTOR); + + delete _this5.controlsTimeout; + }, DELAY_BEFORE_HIDING_CONTROLS); + } + }, { + key: "_hideControls", + value: function _hideControls() { + if (!this.controlsTimeout) { + return; + } + + clearTimeout(this.controlsTimeout); + this.container.classList.remove(CONTROLS_SELECTOR); + delete this.controlsTimeout; + } + }, { + key: "_resetMouseScrollState", + value: function _resetMouseScrollState() { + this.mouseScrollTimeStamp = 0; + this.mouseScrollDelta = 0; + } + }, { + key: "_touchSwipe", + value: function _touchSwipe(evt) { + if (!this.active) { + return; + } + + if (evt.touches.length > 1) { + this.touchSwipeState = null; + return; + } + + switch (evt.type) { + case 'touchstart': + this.touchSwipeState = { + startX: evt.touches[0].pageX, + startY: evt.touches[0].pageY, + endX: evt.touches[0].pageX, + endY: evt.touches[0].pageY + }; + break; + + case 'touchmove': + if (this.touchSwipeState === null) { + return; + } + + this.touchSwipeState.endX = evt.touches[0].pageX; + this.touchSwipeState.endY = evt.touches[0].pageY; + evt.preventDefault(); + break; + + case 'touchend': + if (this.touchSwipeState === null) { + return; + } + + var delta = 0; + var dx = this.touchSwipeState.endX - this.touchSwipeState.startX; + var dy = this.touchSwipeState.endY - this.touchSwipeState.startY; + var absAngle = Math.abs(Math.atan2(dy, dx)); + + if (Math.abs(dx) > SWIPE_MIN_DISTANCE_THRESHOLD && (absAngle <= SWIPE_ANGLE_THRESHOLD || absAngle >= Math.PI - SWIPE_ANGLE_THRESHOLD)) { + delta = dx; + } else if (Math.abs(dy) > SWIPE_MIN_DISTANCE_THRESHOLD && Math.abs(absAngle - Math.PI / 2) <= SWIPE_ANGLE_THRESHOLD) { + delta = dy; + } + + if (delta > 0) { + this._goToPreviousPage(); + } else if (delta < 0) { + this._goToNextPage(); + } + + break; + } + } + }, { + key: "_addWindowListeners", + value: function _addWindowListeners() { + this.showControlsBind = this._showControls.bind(this); + this.mouseDownBind = this._mouseDown.bind(this); + this.mouseWheelBind = this._mouseWheel.bind(this); + this.resetMouseScrollStateBind = this._resetMouseScrollState.bind(this); + this.contextMenuBind = this._contextMenu.bind(this); + this.touchSwipeBind = this._touchSwipe.bind(this); + window.addEventListener('mousemove', this.showControlsBind); + window.addEventListener('mousedown', this.mouseDownBind); + window.addEventListener('wheel', this.mouseWheelBind); + window.addEventListener('keydown', this.resetMouseScrollStateBind); + window.addEventListener('contextmenu', this.contextMenuBind); + window.addEventListener('touchstart', this.touchSwipeBind); + window.addEventListener('touchmove', this.touchSwipeBind); + window.addEventListener('touchend', this.touchSwipeBind); + } + }, { + key: "_removeWindowListeners", + value: function _removeWindowListeners() { + window.removeEventListener('mousemove', this.showControlsBind); + window.removeEventListener('mousedown', this.mouseDownBind); + window.removeEventListener('wheel', this.mouseWheelBind); + window.removeEventListener('keydown', this.resetMouseScrollStateBind); + window.removeEventListener('contextmenu', this.contextMenuBind); + window.removeEventListener('touchstart', this.touchSwipeBind); + window.removeEventListener('touchmove', this.touchSwipeBind); + window.removeEventListener('touchend', this.touchSwipeBind); + delete this.showControlsBind; + delete this.mouseDownBind; + delete this.mouseWheelBind; + delete this.resetMouseScrollStateBind; + delete this.contextMenuBind; + delete this.touchSwipeBind; + } + }, { + key: "_fullscreenChange", + value: function _fullscreenChange() { + if (this.isFullscreen) { + this._enter(); + } else { + this._exit(); + } + } + }, { + key: "_addFullscreenChangeListeners", + value: function _addFullscreenChangeListeners() { + this.fullscreenChangeBind = this._fullscreenChange.bind(this); + window.addEventListener('fullscreenchange', this.fullscreenChangeBind); + window.addEventListener('mozfullscreenchange', this.fullscreenChangeBind); + window.addEventListener('webkitfullscreenchange', this.fullscreenChangeBind); + window.addEventListener('MSFullscreenChange', this.fullscreenChangeBind); + } + }, { + key: "_removeFullscreenChangeListeners", + value: function _removeFullscreenChangeListeners() { + window.removeEventListener('fullscreenchange', this.fullscreenChangeBind); + window.removeEventListener('mozfullscreenchange', this.fullscreenChangeBind); + window.removeEventListener('webkitfullscreenchange', this.fullscreenChangeBind); + window.removeEventListener('MSFullscreenChange', this.fullscreenChangeBind); + delete this.fullscreenChangeBind; + } + }, { + key: "isFullscreen", + get: function get() { + return !!(document.fullscreenElement || document.mozFullScreen || document.webkitIsFullScreen || document.msFullscreenElement); + } + }]); + + return PDFPresentationMode; +}(); + +exports.PDFPresentationMode = PDFPresentationMode; + +/***/ }), +/* 25 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFSidebarResizer = void 0; + +var _ui_utils = __webpack_require__(6); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var SIDEBAR_WIDTH_VAR = '--sidebar-width'; +var SIDEBAR_MIN_WIDTH = 200; +var SIDEBAR_RESIZING_CLASS = 'sidebarResizing'; + +var PDFSidebarResizer = +/*#__PURE__*/ +function () { + function PDFSidebarResizer(options, eventBus) { + var _this = this; + + var l10n = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : _ui_utils.NullL10n; + + _classCallCheck(this, PDFSidebarResizer); + + this.enabled = false; + this.isRTL = false; + this.sidebarOpen = false; + this.doc = document.documentElement; + this._width = null; + this._outerContainerWidth = null; + this._boundEvents = Object.create(null); + this.outerContainer = options.outerContainer; + this.resizer = options.resizer; + this.eventBus = eventBus; + this.l10n = l10n; + + if (typeof CSS === 'undefined' || typeof CSS.supports !== 'function' || !CSS.supports(SIDEBAR_WIDTH_VAR, "calc(-1 * ".concat(SIDEBAR_MIN_WIDTH, "px)"))) { + console.warn('PDFSidebarResizer: ' + 'The browser does not support resizing of the sidebar.'); + return; + } + + this.enabled = true; + this.resizer.classList.remove('hidden'); + this.l10n.getDirection().then(function (dir) { + _this.isRTL = dir === 'rtl'; + }); + + this._addEventListeners(); + } + + _createClass(PDFSidebarResizer, [{ + key: "_updateWidth", + value: function _updateWidth() { + var width = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + + if (!this.enabled) { + return false; + } + + var maxWidth = Math.floor(this.outerContainerWidth / 2); + + if (width > maxWidth) { + width = maxWidth; + } + + if (width < SIDEBAR_MIN_WIDTH) { + width = SIDEBAR_MIN_WIDTH; + } + + if (width === this._width) { + return false; + } + + this._width = width; + this.doc.style.setProperty(SIDEBAR_WIDTH_VAR, "".concat(width, "px")); + return true; + } + }, { + key: "_mouseMove", + value: function _mouseMove(evt) { + var width = evt.clientX; + + if (this.isRTL) { + width = this.outerContainerWidth - width; + } + + this._updateWidth(width); + } + }, { + key: "_mouseUp", + value: function _mouseUp(evt) { + this.outerContainer.classList.remove(SIDEBAR_RESIZING_CLASS); + this.eventBus.dispatch('resize', { + source: this + }); + var _boundEvents = this._boundEvents; + window.removeEventListener('mousemove', _boundEvents.mouseMove); + window.removeEventListener('mouseup', _boundEvents.mouseUp); + } + }, { + key: "_addEventListeners", + value: function _addEventListeners() { + var _this2 = this; + + if (!this.enabled) { + return; + } + + var _boundEvents = this._boundEvents; + _boundEvents.mouseMove = this._mouseMove.bind(this); + _boundEvents.mouseUp = this._mouseUp.bind(this); + this.resizer.addEventListener('mousedown', function (evt) { + if (evt.button !== 0) { + return; + } + + _this2.outerContainer.classList.add(SIDEBAR_RESIZING_CLASS); + + window.addEventListener('mousemove', _boundEvents.mouseMove); + window.addEventListener('mouseup', _boundEvents.mouseUp); + }); + this.eventBus.on('sidebarviewchanged', function (evt) { + _this2.sidebarOpen = !!(evt && evt.view); + }); + this.eventBus.on('resize', function (evt) { + if (evt && evt.source === window) { + _this2._outerContainerWidth = null; + + if (_this2._width) { + if (_this2.sidebarOpen) { + _this2.outerContainer.classList.add(SIDEBAR_RESIZING_CLASS); + + var updated = _this2._updateWidth(_this2._width); + + Promise.resolve().then(function () { + _this2.outerContainer.classList.remove(SIDEBAR_RESIZING_CLASS); + + if (updated) { + _this2.eventBus.dispatch('resize', { + source: _this2 + }); + } + }); + } else { + _this2._updateWidth(_this2._width); + } + } + } + }); + } + }, { + key: "outerContainerWidth", + get: function get() { + if (!this._outerContainerWidth) { + this._outerContainerWidth = this.outerContainer.clientWidth; + } + + return this._outerContainerWidth; + } + }]); + + return PDFSidebarResizer; +}(); + +exports.PDFSidebarResizer = PDFSidebarResizer; + +/***/ }), +/* 26 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFThumbnailViewer = void 0; + +var _ui_utils = __webpack_require__(6); + +var _pdf_thumbnail_view = __webpack_require__(27); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var THUMBNAIL_SCROLL_MARGIN = -19; +var THUMBNAIL_SELECTED_CLASS = 'selected'; + +var PDFThumbnailViewer = +/*#__PURE__*/ +function () { + function PDFThumbnailViewer(_ref) { + var container = _ref.container, + linkService = _ref.linkService, + renderingQueue = _ref.renderingQueue, + _ref$l10n = _ref.l10n, + l10n = _ref$l10n === void 0 ? _ui_utils.NullL10n : _ref$l10n; + + _classCallCheck(this, PDFThumbnailViewer); + + this.container = container; + this.linkService = linkService; + this.renderingQueue = renderingQueue; + this.l10n = l10n; + this.scroll = (0, _ui_utils.watchScroll)(this.container, this._scrollUpdated.bind(this)); + + this._resetView(); + } + + _createClass(PDFThumbnailViewer, [{ + key: "_scrollUpdated", + value: function _scrollUpdated() { + this.renderingQueue.renderHighestPriority(); + } + }, { + key: "getThumbnail", + value: function getThumbnail(index) { + return this._thumbnails[index]; + } + }, { + key: "_getVisibleThumbs", + value: function _getVisibleThumbs() { + return (0, _ui_utils.getVisibleElements)(this.container, this._thumbnails); + } + }, { + key: "scrollThumbnailIntoView", + value: function scrollThumbnailIntoView(pageNumber) { + if (!this.pdfDocument) { + return; + } + + var thumbnailView = this._thumbnails[pageNumber - 1]; + + if (!thumbnailView) { + console.error('scrollThumbnailIntoView: Invalid "pageNumber" parameter.'); + return; + } + + if (pageNumber !== this._currentPageNumber) { + var prevThumbnailView = this._thumbnails[this._currentPageNumber - 1]; + prevThumbnailView.div.classList.remove(THUMBNAIL_SELECTED_CLASS); + thumbnailView.div.classList.add(THUMBNAIL_SELECTED_CLASS); + } + + var visibleThumbs = this._getVisibleThumbs(); + + var numVisibleThumbs = visibleThumbs.views.length; + + if (numVisibleThumbs > 0) { + var first = visibleThumbs.first.id; + var last = numVisibleThumbs > 1 ? visibleThumbs.last.id : first; + var shouldScroll = false; + + if (pageNumber <= first || pageNumber >= last) { + shouldScroll = true; + } else { + visibleThumbs.views.some(function (view) { + if (view.id !== pageNumber) { + return false; + } + + shouldScroll = view.percent < 100; + return true; + }); + } + + if (shouldScroll) { + (0, _ui_utils.scrollIntoView)(thumbnailView.div, { + top: THUMBNAIL_SCROLL_MARGIN + }); + } + } + + this._currentPageNumber = pageNumber; + } + }, { + key: "cleanup", + value: function cleanup() { + _pdf_thumbnail_view.PDFThumbnailView.cleanup(); + } + }, { + key: "_resetView", + value: function _resetView() { + this._thumbnails = []; + this._currentPageNumber = 1; + this._pageLabels = null; + this._pagesRotation = 0; + this._pagesRequests = []; + this.container.textContent = ''; + } + }, { + key: "setDocument", + value: function setDocument(pdfDocument) { + var _this = this; + + if (this.pdfDocument) { + this._cancelRendering(); + + this._resetView(); + } + + this.pdfDocument = pdfDocument; + + if (!pdfDocument) { + return; + } + + pdfDocument.getPage(1).then(function (firstPage) { + var pagesCount = pdfDocument.numPages; + var viewport = firstPage.getViewport({ + scale: 1 + }); + + for (var pageNum = 1; pageNum <= pagesCount; ++pageNum) { + var thumbnail = new _pdf_thumbnail_view.PDFThumbnailView({ + container: _this.container, + id: pageNum, + defaultViewport: viewport.clone(), + linkService: _this.linkService, + renderingQueue: _this.renderingQueue, + disableCanvasToImageConversion: false, + l10n: _this.l10n + }); + + _this._thumbnails.push(thumbnail); + } + + var thumbnailView = _this._thumbnails[_this._currentPageNumber - 1]; + thumbnailView.div.classList.add(THUMBNAIL_SELECTED_CLASS); + }).catch(function (reason) { + console.error('Unable to initialize thumbnail viewer', reason); + }); + } + }, { + key: "_cancelRendering", + value: function _cancelRendering() { + for (var i = 0, ii = this._thumbnails.length; i < ii; i++) { + if (this._thumbnails[i]) { + this._thumbnails[i].cancelRendering(); + } + } + } + }, { + key: "setPageLabels", + value: function setPageLabels(labels) { + if (!this.pdfDocument) { + return; + } + + if (!labels) { + this._pageLabels = null; + } else if (!(Array.isArray(labels) && this.pdfDocument.numPages === labels.length)) { + this._pageLabels = null; + console.error('PDFThumbnailViewer_setPageLabels: Invalid page labels.'); + } else { + this._pageLabels = labels; + } + + for (var i = 0, ii = this._thumbnails.length; i < ii; i++) { + var label = this._pageLabels && this._pageLabels[i]; + + this._thumbnails[i].setPageLabel(label); + } + } + }, { + key: "_ensurePdfPageLoaded", + value: function _ensurePdfPageLoaded(thumbView) { + var _this2 = this; + + if (thumbView.pdfPage) { + return Promise.resolve(thumbView.pdfPage); + } + + var pageNumber = thumbView.id; + + if (this._pagesRequests[pageNumber]) { + return this._pagesRequests[pageNumber]; + } + + var promise = this.pdfDocument.getPage(pageNumber).then(function (pdfPage) { + thumbView.setPdfPage(pdfPage); + _this2._pagesRequests[pageNumber] = null; + return pdfPage; + }).catch(function (reason) { + console.error('Unable to get page for thumb view', reason); + _this2._pagesRequests[pageNumber] = null; + }); + this._pagesRequests[pageNumber] = promise; + return promise; + } + }, { + key: "forceRendering", + value: function forceRendering() { + var _this3 = this; + + var visibleThumbs = this._getVisibleThumbs(); + + var thumbView = this.renderingQueue.getHighestPriority(visibleThumbs, this._thumbnails, this.scroll.down); + + if (thumbView) { + this._ensurePdfPageLoaded(thumbView).then(function () { + _this3.renderingQueue.renderView(thumbView); + }); + + return true; + } + + return false; + } + }, { + key: "pagesRotation", + get: function get() { + return this._pagesRotation; + }, + set: function set(rotation) { + if (!(0, _ui_utils.isValidRotation)(rotation)) { + throw new Error('Invalid thumbnails rotation angle.'); + } + + if (!this.pdfDocument) { + return; + } + + if (this._pagesRotation === rotation) { + return; + } + + this._pagesRotation = rotation; + + for (var i = 0, ii = this._thumbnails.length; i < ii; i++) { + this._thumbnails[i].update(rotation); + } + } + }]); + + return PDFThumbnailViewer; +}(); + +exports.PDFThumbnailViewer = PDFThumbnailViewer; + +/***/ }), +/* 27 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFThumbnailView = void 0; + +var _pdfjsLib = __webpack_require__(7); + +var _ui_utils = __webpack_require__(6); + +var _pdf_rendering_queue = __webpack_require__(10); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var MAX_NUM_SCALING_STEPS = 3; +var THUMBNAIL_CANVAS_BORDER_WIDTH = 1; +var THUMBNAIL_WIDTH = 98; + +var TempImageFactory = function TempImageFactoryClosure() { + var tempCanvasCache = null; + return { + getCanvas: function getCanvas(width, height) { + var tempCanvas = tempCanvasCache; + + if (!tempCanvas) { + tempCanvas = document.createElement('canvas'); + tempCanvasCache = tempCanvas; + } + + tempCanvas.width = width; + tempCanvas.height = height; + tempCanvas.mozOpaque = true; + var ctx = tempCanvas.getContext('2d', { + alpha: false + }); + ctx.save(); + ctx.fillStyle = 'rgb(255, 255, 255)'; + ctx.fillRect(0, 0, width, height); + ctx.restore(); + return tempCanvas; + }, + destroyCanvas: function destroyCanvas() { + var tempCanvas = tempCanvasCache; + + if (tempCanvas) { + tempCanvas.width = 0; + tempCanvas.height = 0; + } + + tempCanvasCache = null; + } + }; +}(); + +var PDFThumbnailView = +/*#__PURE__*/ +function () { + function PDFThumbnailView(_ref) { + var container = _ref.container, + id = _ref.id, + defaultViewport = _ref.defaultViewport, + linkService = _ref.linkService, + renderingQueue = _ref.renderingQueue, + _ref$disableCanvasToI = _ref.disableCanvasToImageConversion, + disableCanvasToImageConversion = _ref$disableCanvasToI === void 0 ? false : _ref$disableCanvasToI, + _ref$l10n = _ref.l10n, + l10n = _ref$l10n === void 0 ? _ui_utils.NullL10n : _ref$l10n; + + _classCallCheck(this, PDFThumbnailView); + + this.id = id; + this.renderingId = 'thumbnail' + id; + this.pageLabel = null; + this.pdfPage = null; + this.rotation = 0; + this.viewport = defaultViewport; + this.pdfPageRotate = defaultViewport.rotation; + this.linkService = linkService; + this.renderingQueue = renderingQueue; + this.renderTask = null; + this.renderingState = _pdf_rendering_queue.RenderingStates.INITIAL; + this.resume = null; + this.disableCanvasToImageConversion = disableCanvasToImageConversion; + this.pageWidth = this.viewport.width; + this.pageHeight = this.viewport.height; + this.pageRatio = this.pageWidth / this.pageHeight; + this.canvasWidth = THUMBNAIL_WIDTH; + this.canvasHeight = this.canvasWidth / this.pageRatio | 0; + this.scale = this.canvasWidth / this.pageWidth; + this.l10n = l10n; + var anchor = document.createElement('a'); + anchor.href = linkService.getAnchorUrl('#page=' + id); + this.l10n.get('thumb_page_title', { + page: id + }, 'Page {{page}}').then(function (msg) { + anchor.title = msg; + }); + + anchor.onclick = function () { + linkService.page = id; + return false; + }; + + this.anchor = anchor; + var div = document.createElement('div'); + div.className = 'thumbnail'; + div.setAttribute('data-page-number', this.id); + this.div = div; + var ring = document.createElement('div'); + ring.className = 'thumbnailSelectionRing'; + var borderAdjustment = 2 * THUMBNAIL_CANVAS_BORDER_WIDTH; + ring.style.width = this.canvasWidth + borderAdjustment + 'px'; + ring.style.height = this.canvasHeight + borderAdjustment + 'px'; + this.ring = ring; + div.appendChild(ring); + anchor.appendChild(div); + container.appendChild(anchor); + } + + _createClass(PDFThumbnailView, [{ + key: "setPdfPage", + value: function setPdfPage(pdfPage) { + this.pdfPage = pdfPage; + this.pdfPageRotate = pdfPage.rotate; + var totalRotation = (this.rotation + this.pdfPageRotate) % 360; + this.viewport = pdfPage.getViewport({ + scale: 1, + rotation: totalRotation + }); + this.reset(); + } + }, { + key: "reset", + value: function reset() { + this.cancelRendering(); + this.pageWidth = this.viewport.width; + this.pageHeight = this.viewport.height; + this.pageRatio = this.pageWidth / this.pageHeight; + this.canvasHeight = this.canvasWidth / this.pageRatio | 0; + this.scale = this.canvasWidth / this.pageWidth; + this.div.removeAttribute('data-loaded'); + var ring = this.ring; + var childNodes = ring.childNodes; + + for (var i = childNodes.length - 1; i >= 0; i--) { + ring.removeChild(childNodes[i]); + } + + var borderAdjustment = 2 * THUMBNAIL_CANVAS_BORDER_WIDTH; + ring.style.width = this.canvasWidth + borderAdjustment + 'px'; + ring.style.height = this.canvasHeight + borderAdjustment + 'px'; + + if (this.canvas) { + this.canvas.width = 0; + this.canvas.height = 0; + delete this.canvas; + } + + if (this.image) { + this.image.removeAttribute('src'); + delete this.image; + } + } + }, { + key: "update", + value: function update(rotation) { + if (typeof rotation !== 'undefined') { + this.rotation = rotation; + } + + var totalRotation = (this.rotation + this.pdfPageRotate) % 360; + this.viewport = this.viewport.clone({ + scale: 1, + rotation: totalRotation + }); + this.reset(); + } + }, { + key: "cancelRendering", + value: function cancelRendering() { + if (this.renderTask) { + this.renderTask.cancel(); + this.renderTask = null; + } + + this.renderingState = _pdf_rendering_queue.RenderingStates.INITIAL; + this.resume = null; + } + }, { + key: "_getPageDrawContext", + value: function _getPageDrawContext() { + var noCtxScale = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + var canvas = document.createElement('canvas'); + this.canvas = canvas; + canvas.mozOpaque = true; + var ctx = canvas.getContext('2d', { + alpha: false + }); + var outputScale = (0, _ui_utils.getOutputScale)(ctx); + canvas.width = this.canvasWidth * outputScale.sx | 0; + canvas.height = this.canvasHeight * outputScale.sy | 0; + canvas.style.width = this.canvasWidth + 'px'; + canvas.style.height = this.canvasHeight + 'px'; + + if (!noCtxScale && outputScale.scaled) { + ctx.scale(outputScale.sx, outputScale.sy); + } + + return ctx; + } + }, { + key: "_convertCanvasToImage", + value: function _convertCanvasToImage() { + var _this = this; + + if (!this.canvas) { + return; + } + + if (this.renderingState !== _pdf_rendering_queue.RenderingStates.FINISHED) { + return; + } + + var id = this.renderingId; + var className = 'thumbnailImage'; + + if (this.disableCanvasToImageConversion) { + this.canvas.id = id; + this.canvas.className = className; + this.l10n.get('thumb_page_canvas', { + page: this.pageId + }, 'Thumbnail of Page {{page}}').then(function (msg) { + _this.canvas.setAttribute('aria-label', msg); + }); + this.div.setAttribute('data-loaded', true); + this.ring.appendChild(this.canvas); + return; + } + + var image = document.createElement('img'); + image.id = id; + image.className = className; + this.l10n.get('thumb_page_canvas', { + page: this.pageId + }, 'Thumbnail of Page {{page}}').then(function (msg) { + image.setAttribute('aria-label', msg); + }); + image.style.width = this.canvasWidth + 'px'; + image.style.height = this.canvasHeight + 'px'; + image.src = this.canvas.toDataURL(); + this.image = image; + this.div.setAttribute('data-loaded', true); + this.ring.appendChild(image); + this.canvas.width = 0; + this.canvas.height = 0; + delete this.canvas; + } + }, { + key: "draw", + value: function draw() { + var _this2 = this; + + if (this.renderingState !== _pdf_rendering_queue.RenderingStates.INITIAL) { + console.error('Must be in new state before drawing'); + return Promise.resolve(undefined); + } + + this.renderingState = _pdf_rendering_queue.RenderingStates.RUNNING; + var renderCapability = (0, _pdfjsLib.createPromiseCapability)(); + + var finishRenderTask = function finishRenderTask(error) { + if (renderTask === _this2.renderTask) { + _this2.renderTask = null; + } + + if (error instanceof _pdfjsLib.RenderingCancelledException) { + renderCapability.resolve(undefined); + return; + } + + _this2.renderingState = _pdf_rendering_queue.RenderingStates.FINISHED; + + _this2._convertCanvasToImage(); + + if (!error) { + renderCapability.resolve(undefined); + } else { + renderCapability.reject(error); + } + }; + + var ctx = this._getPageDrawContext(); + + var drawViewport = this.viewport.clone({ + scale: this.scale + }); + + var renderContinueCallback = function renderContinueCallback(cont) { + if (!_this2.renderingQueue.isHighestPriority(_this2)) { + _this2.renderingState = _pdf_rendering_queue.RenderingStates.PAUSED; + + _this2.resume = function () { + _this2.renderingState = _pdf_rendering_queue.RenderingStates.RUNNING; + cont(); + }; + + return; + } + + cont(); + }; + + var renderContext = { + canvasContext: ctx, + viewport: drawViewport + }; + var renderTask = this.renderTask = this.pdfPage.render(renderContext); + renderTask.onContinue = renderContinueCallback; + renderTask.promise.then(function () { + finishRenderTask(null); + }, function (error) { + finishRenderTask(error); + }); + return renderCapability.promise; + } + }, { + key: "setImage", + value: function setImage(pageView) { + if (this.renderingState !== _pdf_rendering_queue.RenderingStates.INITIAL) { + return; + } + + var img = pageView.canvas; + + if (!img) { + return; + } + + if (!this.pdfPage) { + this.setPdfPage(pageView.pdfPage); + } + + this.renderingState = _pdf_rendering_queue.RenderingStates.FINISHED; + + var ctx = this._getPageDrawContext(true); + + var canvas = ctx.canvas; + + if (img.width <= 2 * canvas.width) { + ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, canvas.width, canvas.height); + + this._convertCanvasToImage(); + + return; + } + + var reducedWidth = canvas.width << MAX_NUM_SCALING_STEPS; + var reducedHeight = canvas.height << MAX_NUM_SCALING_STEPS; + var reducedImage = TempImageFactory.getCanvas(reducedWidth, reducedHeight); + var reducedImageCtx = reducedImage.getContext('2d'); + + while (reducedWidth > img.width || reducedHeight > img.height) { + reducedWidth >>= 1; + reducedHeight >>= 1; + } + + reducedImageCtx.drawImage(img, 0, 0, img.width, img.height, 0, 0, reducedWidth, reducedHeight); + + while (reducedWidth > 2 * canvas.width) { + reducedImageCtx.drawImage(reducedImage, 0, 0, reducedWidth, reducedHeight, 0, 0, reducedWidth >> 1, reducedHeight >> 1); + reducedWidth >>= 1; + reducedHeight >>= 1; + } + + ctx.drawImage(reducedImage, 0, 0, reducedWidth, reducedHeight, 0, 0, canvas.width, canvas.height); + + this._convertCanvasToImage(); + } + }, { + key: "setPageLabel", + value: function setPageLabel(label) { + var _this3 = this; + + this.pageLabel = typeof label === 'string' ? label : null; + this.l10n.get('thumb_page_title', { + page: this.pageId + }, 'Page {{page}}').then(function (msg) { + _this3.anchor.title = msg; + }); + + if (this.renderingState !== _pdf_rendering_queue.RenderingStates.FINISHED) { + return; + } + + this.l10n.get('thumb_page_canvas', { + page: this.pageId + }, 'Thumbnail of Page {{page}}').then(function (ariaLabel) { + if (_this3.image) { + _this3.image.setAttribute('aria-label', ariaLabel); + } else if (_this3.disableCanvasToImageConversion && _this3.canvas) { + _this3.canvas.setAttribute('aria-label', ariaLabel); + } + }); + } + }, { + key: "pageId", + get: function get() { + return this.pageLabel !== null ? this.pageLabel : this.id; + } + }], [{ + key: "cleanup", + value: function cleanup() { + TempImageFactory.destroyCanvas(); + } + }]); + + return PDFThumbnailView; +}(); + +exports.PDFThumbnailView = PDFThumbnailView; + +/***/ }), +/* 28 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFViewer = void 0; + +var _base_viewer = __webpack_require__(29); + +var _pdfjsLib = __webpack_require__(7); + +function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } return _assertThisInitialized(self); } + +function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } + +function _get(target, property, receiver) { if (typeof Reflect !== "undefined" && Reflect.get) { _get = Reflect.get; } else { _get = function _get(target, property, receiver) { var base = _superPropBase(target, property); if (!base) return; var desc = Object.getOwnPropertyDescriptor(base, property); if (desc.get) { return desc.get.call(receiver); } return desc.value; }; } return _get(target, property, receiver || target); } + +function _superPropBase(object, property) { while (!Object.prototype.hasOwnProperty.call(object, property)) { object = _getPrototypeOf(object); if (object === null) break; } return object; } + +function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); } + +function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); } + +function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } + +var PDFViewer = +/*#__PURE__*/ +function (_BaseViewer) { + _inherits(PDFViewer, _BaseViewer); + + function PDFViewer() { + _classCallCheck(this, PDFViewer); + + return _possibleConstructorReturn(this, _getPrototypeOf(PDFViewer).apply(this, arguments)); + } + + _createClass(PDFViewer, [{ + key: "_scrollIntoView", + value: function _scrollIntoView(_ref) { + var pageDiv = _ref.pageDiv, + _ref$pageSpot = _ref.pageSpot, + pageSpot = _ref$pageSpot === void 0 ? null : _ref$pageSpot, + _ref$pageNumber = _ref.pageNumber, + pageNumber = _ref$pageNumber === void 0 ? null : _ref$pageNumber; + + if (!pageSpot && !this.isInPresentationMode) { + var left = pageDiv.offsetLeft + pageDiv.clientLeft; + var right = left + pageDiv.clientWidth; + var _this$container = this.container, + scrollLeft = _this$container.scrollLeft, + clientWidth = _this$container.clientWidth; + + if (this._isScrollModeHorizontal || left < scrollLeft || right > scrollLeft + clientWidth) { + pageSpot = { + left: 0, + top: 0 + }; + } + } + + _get(_getPrototypeOf(PDFViewer.prototype), "_scrollIntoView", this).call(this, { + pageDiv: pageDiv, + pageSpot: pageSpot, + pageNumber: pageNumber + }); + } + }, { + key: "_getVisiblePages", + value: function _getVisiblePages() { + if (this.isInPresentationMode) { + return this._getCurrentVisiblePage(); + } + + return _get(_getPrototypeOf(PDFViewer.prototype), "_getVisiblePages", this).call(this); + } + }, { + key: "_updateHelper", + value: function _updateHelper(visiblePages) { + if (this.isInPresentationMode) { + return; + } + + var currentId = this._currentPageNumber; + var stillFullyVisible = false; + var _iteratorNormalCompletion = true; + var _didIteratorError = false; + var _iteratorError = undefined; + + try { + for (var _iterator = visiblePages[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { + var page = _step.value; + + if (page.percent < 100) { + break; + } + + if (page.id === currentId) { + stillFullyVisible = true; + break; + } + } + } catch (err) { + _didIteratorError = true; + _iteratorError = err; + } finally { + try { + if (!_iteratorNormalCompletion && _iterator.return != null) { + _iterator.return(); + } + } finally { + if (_didIteratorError) { + throw _iteratorError; + } + } + } + + if (!stillFullyVisible) { + currentId = visiblePages[0].id; + } + + this._setCurrentPageNumber(currentId); + } + }, { + key: "_setDocumentViewerElement", + get: function get() { + return (0, _pdfjsLib.shadow)(this, '_setDocumentViewerElement', this.viewer); + } + }]); + + return PDFViewer; +}(_base_viewer.BaseViewer); + +exports.PDFViewer = PDFViewer; + +/***/ }), +/* 29 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.BaseViewer = void 0; + +var _ui_utils = __webpack_require__(6); + +var _pdf_rendering_queue = __webpack_require__(10); + +var _annotation_layer_builder = __webpack_require__(30); + +var _pdfjsLib = __webpack_require__(7); + +var _pdf_page_view = __webpack_require__(31); + +var _pdf_link_service = __webpack_require__(22); + +var _text_layer_builder = __webpack_require__(32); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var DEFAULT_CACHE_SIZE = 10; + +function PDFPageViewBuffer(size) { + var data = []; + + this.push = function (view) { + var i = data.indexOf(view); + + if (i >= 0) { + data.splice(i, 1); + } + + data.push(view); + + if (data.length > size) { + data.shift().destroy(); + } + }; + + this.resize = function (newSize, pagesToKeep) { + size = newSize; + + if (pagesToKeep) { + var pageIdsToKeep = new Set(); + + for (var i = 0, iMax = pagesToKeep.length; i < iMax; ++i) { + pageIdsToKeep.add(pagesToKeep[i].id); + } + + (0, _ui_utils.moveToEndOfArray)(data, function (page) { + return pageIdsToKeep.has(page.id); + }); + } + + while (data.length > size) { + data.shift().destroy(); + } + }; +} + +function isSameScale(oldScale, newScale) { + if (newScale === oldScale) { + return true; + } + + if (Math.abs(newScale - oldScale) < 1e-15) { + return true; + } + + return false; +} + +var BaseViewer = +/*#__PURE__*/ +function () { + function BaseViewer(options) { + var _this = this; + + _classCallCheck(this, BaseViewer); + + if (this.constructor === BaseViewer) { + throw new Error('Cannot initialize BaseViewer.'); + } + + this._name = this.constructor.name; + this.container = options.container; + this.viewer = options.viewer || options.container.firstElementChild; + this.eventBus = options.eventBus || (0, _ui_utils.getGlobalEventBus)(); + this.linkService = options.linkService || new _pdf_link_service.SimpleLinkService(); + this.downloadManager = options.downloadManager || null; + this.findController = options.findController || null; + this.removePageBorders = options.removePageBorders || false; + this.textLayerMode = Number.isInteger(options.textLayerMode) ? options.textLayerMode : _ui_utils.TextLayerMode.ENABLE; + this.imageResourcesPath = options.imageResourcesPath || ''; + this.renderInteractiveForms = options.renderInteractiveForms || false; + this.enablePrintAutoRotate = options.enablePrintAutoRotate || false; + this.renderer = options.renderer || _ui_utils.RendererType.CANVAS; + this.enableWebGL = options.enableWebGL || false; + this.useOnlyCssZoom = options.useOnlyCssZoom || false; + this.maxCanvasPixels = options.maxCanvasPixels; + this.l10n = options.l10n || _ui_utils.NullL10n; + this.defaultRenderingQueue = !options.renderingQueue; + + if (this.defaultRenderingQueue) { + this.renderingQueue = new _pdf_rendering_queue.PDFRenderingQueue(); + this.renderingQueue.setViewer(this); + } else { + this.renderingQueue = options.renderingQueue; + } + + this.scroll = (0, _ui_utils.watchScroll)(this.container, this._scrollUpdate.bind(this)); + this.presentationModeState = _ui_utils.PresentationModeState.UNKNOWN; + + this._resetView(); + + if (this.removePageBorders) { + this.viewer.classList.add('removePageBorders'); + } + + Promise.resolve().then(function () { + _this.eventBus.dispatch('baseviewerinit', { + source: _this + }); + }); + } + + _createClass(BaseViewer, [{ + key: "getPageView", + value: function getPageView(index) { + return this._pages[index]; + } + }, { + key: "_setCurrentPageNumber", + value: function _setCurrentPageNumber(val) { + var resetCurrentPageView = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + + if (this._currentPageNumber === val) { + if (resetCurrentPageView) { + this._resetCurrentPageView(); + } + + return true; + } + + if (!(0 < val && val <= this.pagesCount)) { + return false; + } + + this._currentPageNumber = val; + this.eventBus.dispatch('pagechanging', { + source: this, + pageNumber: val, + pageLabel: this._pageLabels && this._pageLabels[val - 1] + }); + + if (resetCurrentPageView) { + this._resetCurrentPageView(); + } + + return true; + } + }, { + key: "setDocument", + value: function setDocument(pdfDocument) { + var _this2 = this; + + if (this.pdfDocument) { + this._cancelRendering(); + + this._resetView(); + + if (this.findController) { + this.findController.setDocument(null); + } + } + + this.pdfDocument = pdfDocument; + + if (!pdfDocument) { + return; + } + + var pagesCount = pdfDocument.numPages; + var pagesCapability = (0, _pdfjsLib.createPromiseCapability)(); + this.pagesPromise = pagesCapability.promise; + pagesCapability.promise.then(function () { + _this2._pageViewsReady = true; + + _this2.eventBus.dispatch('pagesloaded', { + source: _this2, + pagesCount: pagesCount + }); + }); + var onePageRenderedCapability = (0, _pdfjsLib.createPromiseCapability)(); + this.onePageRendered = onePageRenderedCapability.promise; + + var bindOnAfterAndBeforeDraw = function bindOnAfterAndBeforeDraw(pageView) { + pageView.onBeforeDraw = function () { + _this2._buffer.push(pageView); + }; + + pageView.onAfterDraw = function () { + if (!onePageRenderedCapability.settled) { + onePageRenderedCapability.resolve(); + } + }; + }; + + var firstPagePromise = pdfDocument.getPage(1); + this.firstPagePromise = firstPagePromise; + firstPagePromise.then(function (pdfPage) { + var scale = _this2.currentScale; + var viewport = pdfPage.getViewport({ + scale: scale * _ui_utils.CSS_UNITS + }); + + for (var pageNum = 1; pageNum <= pagesCount; ++pageNum) { + var textLayerFactory = null; + + if (_this2.textLayerMode !== _ui_utils.TextLayerMode.DISABLE) { + textLayerFactory = _this2; + } + + var pageView = new _pdf_page_view.PDFPageView({ + container: _this2._setDocumentViewerElement, + eventBus: _this2.eventBus, + id: pageNum, + scale: scale, + defaultViewport: viewport.clone(), + renderingQueue: _this2.renderingQueue, + textLayerFactory: textLayerFactory, + textLayerMode: _this2.textLayerMode, + annotationLayerFactory: _this2, + imageResourcesPath: _this2.imageResourcesPath, + renderInteractiveForms: _this2.renderInteractiveForms, + renderer: _this2.renderer, + enableWebGL: _this2.enableWebGL, + useOnlyCssZoom: _this2.useOnlyCssZoom, + maxCanvasPixels: _this2.maxCanvasPixels, + l10n: _this2.l10n + }); + bindOnAfterAndBeforeDraw(pageView); + + _this2._pages.push(pageView); + } + + if (_this2._spreadMode !== _ui_utils.SpreadMode.NONE) { + _this2._updateSpreadMode(); + } + + onePageRenderedCapability.promise.then(function () { + if (pdfDocument.loadingParams['disableAutoFetch']) { + pagesCapability.resolve(); + return; + } + + var getPagesLeft = pagesCount; + + var _loop = function _loop(_pageNum) { + pdfDocument.getPage(_pageNum).then(function (pdfPage) { + var pageView = _this2._pages[_pageNum - 1]; + + if (!pageView.pdfPage) { + pageView.setPdfPage(pdfPage); + } + + _this2.linkService.cachePageRef(_pageNum, pdfPage.ref); + + if (--getPagesLeft === 0) { + pagesCapability.resolve(); + } + }, function (reason) { + console.error("Unable to get page ".concat(_pageNum, " to initialize viewer"), reason); + + if (--getPagesLeft === 0) { + pagesCapability.resolve(); + } + }); + }; + + for (var _pageNum = 1; _pageNum <= pagesCount; ++_pageNum) { + _loop(_pageNum); + } + }); + + _this2.eventBus.dispatch('pagesinit', { + source: _this2 + }); + + if (_this2.findController) { + _this2.findController.setDocument(pdfDocument); + } + + if (_this2.defaultRenderingQueue) { + _this2.update(); + } + }).catch(function (reason) { + console.error('Unable to initialize viewer', reason); + }); + } + }, { + key: "setPageLabels", + value: function setPageLabels(labels) { + if (!this.pdfDocument) { + return; + } + + if (!labels) { + this._pageLabels = null; + } else if (!(Array.isArray(labels) && this.pdfDocument.numPages === labels.length)) { + this._pageLabels = null; + console.error("".concat(this._name, ".setPageLabels: Invalid page labels.")); + } else { + this._pageLabels = labels; + } + + for (var i = 0, ii = this._pages.length; i < ii; i++) { + var pageView = this._pages[i]; + var label = this._pageLabels && this._pageLabels[i]; + pageView.setPageLabel(label); + } + } + }, { + key: "_resetView", + value: function _resetView() { + this._pages = []; + this._currentPageNumber = 1; + this._currentScale = _ui_utils.UNKNOWN_SCALE; + this._currentScaleValue = null; + this._pageLabels = null; + this._buffer = new PDFPageViewBuffer(DEFAULT_CACHE_SIZE); + this._location = null; + this._pagesRotation = 0; + this._pagesRequests = []; + this._pageViewsReady = false; + this._scrollMode = _ui_utils.ScrollMode.VERTICAL; + this._spreadMode = _ui_utils.SpreadMode.NONE; + this.viewer.textContent = ''; + + this._updateScrollMode(); + } + }, { + key: "_scrollUpdate", + value: function _scrollUpdate() { + if (this.pagesCount === 0) { + return; + } + + this.update(); + } + }, { + key: "_scrollIntoView", + value: function _scrollIntoView(_ref) { + var pageDiv = _ref.pageDiv, + _ref$pageSpot = _ref.pageSpot, + pageSpot = _ref$pageSpot === void 0 ? null : _ref$pageSpot, + _ref$pageNumber = _ref.pageNumber, + pageNumber = _ref$pageNumber === void 0 ? null : _ref$pageNumber; + (0, _ui_utils.scrollIntoView)(pageDiv, pageSpot); + } + }, { + key: "_setScaleUpdatePages", + value: function _setScaleUpdatePages(newScale, newValue) { + var noScroll = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + var preset = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; + this._currentScaleValue = newValue.toString(); + + if (isSameScale(this._currentScale, newScale)) { + if (preset) { + this.eventBus.dispatch('scalechanging', { + source: this, + scale: newScale, + presetValue: newValue + }); + } + + return; + } + + for (var i = 0, ii = this._pages.length; i < ii; i++) { + this._pages[i].update(newScale); + } + + this._currentScale = newScale; + + if (!noScroll) { + var page = this._currentPageNumber, + dest; + + if (this._location && !(this.isInPresentationMode || this.isChangingPresentationMode)) { + page = this._location.pageNumber; + dest = [null, { + name: 'XYZ' + }, this._location.left, this._location.top, null]; + } + + this.scrollPageIntoView({ + pageNumber: page, + destArray: dest, + allowNegativeOffset: true + }); + } + + this.eventBus.dispatch('scalechanging', { + source: this, + scale: newScale, + presetValue: preset ? newValue : undefined + }); + + if (this.defaultRenderingQueue) { + this.update(); + } + } + }, { + key: "_setScale", + value: function _setScale(value) { + var noScroll = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + var scale = parseFloat(value); + + if (scale > 0) { + this._setScaleUpdatePages(scale, value, noScroll, false); + } else { + var currentPage = this._pages[this._currentPageNumber - 1]; + + if (!currentPage) { + return; + } + + var noPadding = this.isInPresentationMode || this.removePageBorders; + var hPadding = noPadding ? 0 : _ui_utils.SCROLLBAR_PADDING; + var vPadding = noPadding ? 0 : _ui_utils.VERTICAL_PADDING; + + if (!noPadding && this._isScrollModeHorizontal) { + var _ref2 = [vPadding, hPadding]; + hPadding = _ref2[0]; + vPadding = _ref2[1]; + } + + var pageWidthScale = (this.container.clientWidth - hPadding) / currentPage.width * currentPage.scale; + var pageHeightScale = (this.container.clientHeight - vPadding) / currentPage.height * currentPage.scale; + + switch (value) { + case 'page-actual': + scale = 1; + break; + + case 'page-width': + scale = pageWidthScale; + break; + + case 'page-height': + scale = pageHeightScale; + break; + + case 'page-fit': + scale = Math.min(pageWidthScale, pageHeightScale); + break; + + case 'auto': + var horizontalScale = (0, _ui_utils.isPortraitOrientation)(currentPage) ? pageWidthScale : Math.min(pageHeightScale, pageWidthScale); + scale = Math.min(_ui_utils.MAX_AUTO_SCALE, horizontalScale); + break; + + default: + console.error("".concat(this._name, "._setScale: \"").concat(value, "\" is an unknown zoom value.")); + return; + } + + this._setScaleUpdatePages(scale, value, noScroll, true); + } + } + }, { + key: "_resetCurrentPageView", + value: function _resetCurrentPageView() { + if (this.isInPresentationMode) { + this._setScale(this._currentScaleValue, true); + } + + var pageView = this._pages[this._currentPageNumber - 1]; + + this._scrollIntoView({ + pageDiv: pageView.div + }); + } + }, { + key: "scrollPageIntoView", + value: function scrollPageIntoView(_ref3) { + var pageNumber = _ref3.pageNumber, + _ref3$destArray = _ref3.destArray, + destArray = _ref3$destArray === void 0 ? null : _ref3$destArray, + _ref3$allowNegativeOf = _ref3.allowNegativeOffset, + allowNegativeOffset = _ref3$allowNegativeOf === void 0 ? false : _ref3$allowNegativeOf; + + if (!this.pdfDocument) { + return; + } + + var pageView = Number.isInteger(pageNumber) && this._pages[pageNumber - 1]; + + if (!pageView) { + console.error("".concat(this._name, ".scrollPageIntoView: ") + "\"".concat(pageNumber, "\" is not a valid pageNumber parameter.")); + return; + } + + if (this.isInPresentationMode || !destArray) { + this._setCurrentPageNumber(pageNumber, true); + + return; + } + + var x = 0, + y = 0; + var width = 0, + height = 0, + widthScale, + heightScale; + var changeOrientation = pageView.rotation % 180 === 0 ? false : true; + var pageWidth = (changeOrientation ? pageView.height : pageView.width) / pageView.scale / _ui_utils.CSS_UNITS; + var pageHeight = (changeOrientation ? pageView.width : pageView.height) / pageView.scale / _ui_utils.CSS_UNITS; + var scale = 0; + + switch (destArray[1].name) { + case 'XYZ': + x = destArray[2]; + y = destArray[3]; + scale = destArray[4]; + x = x !== null ? x : 0; + y = y !== null ? y : pageHeight; + break; + + case 'Fit': + case 'FitB': + scale = 'page-fit'; + break; + + case 'FitH': + case 'FitBH': + y = destArray[2]; + scale = 'page-width'; + + if (y === null && this._location) { + x = this._location.left; + y = this._location.top; + } + + break; + + case 'FitV': + case 'FitBV': + x = destArray[2]; + width = pageWidth; + height = pageHeight; + scale = 'page-height'; + break; + + case 'FitR': + x = destArray[2]; + y = destArray[3]; + width = destArray[4] - x; + height = destArray[5] - y; + var hPadding = this.removePageBorders ? 0 : _ui_utils.SCROLLBAR_PADDING; + var vPadding = this.removePageBorders ? 0 : _ui_utils.VERTICAL_PADDING; + widthScale = (this.container.clientWidth - hPadding) / width / _ui_utils.CSS_UNITS; + heightScale = (this.container.clientHeight - vPadding) / height / _ui_utils.CSS_UNITS; + scale = Math.min(Math.abs(widthScale), Math.abs(heightScale)); + break; + + default: + console.error("".concat(this._name, ".scrollPageIntoView: ") + "\"".concat(destArray[1].name, "\" is not a valid destination type.")); + return; + } + + if (scale && scale !== this._currentScale) { + this.currentScaleValue = scale; + } else if (this._currentScale === _ui_utils.UNKNOWN_SCALE) { + this.currentScaleValue = _ui_utils.DEFAULT_SCALE_VALUE; + } + + if (scale === 'page-fit' && !destArray[4]) { + this._scrollIntoView({ + pageDiv: pageView.div, + pageNumber: pageNumber + }); + + return; + } + + var boundingRect = [pageView.viewport.convertToViewportPoint(x, y), pageView.viewport.convertToViewportPoint(x + width, y + height)]; + var left = Math.min(boundingRect[0][0], boundingRect[1][0]); + var top = Math.min(boundingRect[0][1], boundingRect[1][1]); + + if (!allowNegativeOffset) { + left = Math.max(left, 0); + top = Math.max(top, 0); + } + + this._scrollIntoView({ + pageDiv: pageView.div, + pageSpot: { + left: left, + top: top + }, + pageNumber: pageNumber + }); + } + }, { + key: "_updateLocation", + value: function _updateLocation(firstPage) { + var currentScale = this._currentScale; + var currentScaleValue = this._currentScaleValue; + var normalizedScaleValue = parseFloat(currentScaleValue) === currentScale ? Math.round(currentScale * 10000) / 100 : currentScaleValue; + var pageNumber = firstPage.id; + var pdfOpenParams = '#page=' + pageNumber; + pdfOpenParams += '&zoom=' + normalizedScaleValue; + var currentPageView = this._pages[pageNumber - 1]; + var container = this.container; + var topLeft = currentPageView.getPagePoint(container.scrollLeft - firstPage.x, container.scrollTop - firstPage.y); + var intLeft = Math.round(topLeft[0]); + var intTop = Math.round(topLeft[1]); + pdfOpenParams += ',' + intLeft + ',' + intTop; + this._location = { + pageNumber: pageNumber, + scale: normalizedScaleValue, + top: intTop, + left: intLeft, + rotation: this._pagesRotation, + pdfOpenParams: pdfOpenParams + }; + } + }, { + key: "_updateHelper", + value: function _updateHelper(visiblePages) { + throw new Error('Not implemented: _updateHelper'); + } + }, { + key: "update", + value: function update() { + var visible = this._getVisiblePages(); + + var visiblePages = visible.views, + numVisiblePages = visiblePages.length; + + if (numVisiblePages === 0) { + return; + } + + var newCacheSize = Math.max(DEFAULT_CACHE_SIZE, 2 * numVisiblePages + 1); + + this._buffer.resize(newCacheSize, visiblePages); + + this.renderingQueue.renderHighestPriority(visible); + + this._updateHelper(visiblePages); + + this._updateLocation(visible.first); + + this.eventBus.dispatch('updateviewarea', { + source: this, + location: this._location + }); + } + }, { + key: "containsElement", + value: function containsElement(element) { + return this.container.contains(element); + } + }, { + key: "focus", + value: function focus() { + this.container.focus(); + } + }, { + key: "_getCurrentVisiblePage", + value: function _getCurrentVisiblePage() { + if (!this.pagesCount) { + return { + views: [] + }; + } + + var pageView = this._pages[this._currentPageNumber - 1]; + var element = pageView.div; + var view = { + id: pageView.id, + x: element.offsetLeft + element.clientLeft, + y: element.offsetTop + element.clientTop, + view: pageView + }; + return { + first: view, + last: view, + views: [view] + }; + } + }, { + key: "_getVisiblePages", + value: function _getVisiblePages() { + return (0, _ui_utils.getVisibleElements)(this.container, this._pages, true, this._isScrollModeHorizontal); + } + }, { + key: "isPageVisible", + value: function isPageVisible(pageNumber) { + if (!this.pdfDocument) { + return false; + } + + if (this.pageNumber < 1 || pageNumber > this.pagesCount) { + console.error("".concat(this._name, ".isPageVisible: \"").concat(pageNumber, "\" is out of bounds.")); + return false; + } + + return this._getVisiblePages().views.some(function (view) { + return view.id === pageNumber; + }); + } + }, { + key: "cleanup", + value: function cleanup() { + for (var i = 0, ii = this._pages.length; i < ii; i++) { + if (this._pages[i] && this._pages[i].renderingState !== _pdf_rendering_queue.RenderingStates.FINISHED) { + this._pages[i].reset(); + } + } + } + }, { + key: "_cancelRendering", + value: function _cancelRendering() { + for (var i = 0, ii = this._pages.length; i < ii; i++) { + if (this._pages[i]) { + this._pages[i].cancelRendering(); + } + } + } + }, { + key: "_ensurePdfPageLoaded", + value: function _ensurePdfPageLoaded(pageView) { + var _this3 = this; + + if (pageView.pdfPage) { + return Promise.resolve(pageView.pdfPage); + } + + var pageNumber = pageView.id; + + if (this._pagesRequests[pageNumber]) { + return this._pagesRequests[pageNumber]; + } + + var promise = this.pdfDocument.getPage(pageNumber).then(function (pdfPage) { + if (!pageView.pdfPage) { + pageView.setPdfPage(pdfPage); + } + + _this3._pagesRequests[pageNumber] = null; + return pdfPage; + }).catch(function (reason) { + console.error('Unable to get page for page view', reason); + _this3._pagesRequests[pageNumber] = null; + }); + this._pagesRequests[pageNumber] = promise; + return promise; + } + }, { + key: "forceRendering", + value: function forceRendering(currentlyVisiblePages) { + var _this4 = this; + + var visiblePages = currentlyVisiblePages || this._getVisiblePages(); + + var scrollAhead = this._isScrollModeHorizontal ? this.scroll.right : this.scroll.down; + var pageView = this.renderingQueue.getHighestPriority(visiblePages, this._pages, scrollAhead); + + if (pageView) { + this._ensurePdfPageLoaded(pageView).then(function () { + _this4.renderingQueue.renderView(pageView); + }); + + return true; + } + + return false; + } + }, { + key: "createTextLayerBuilder", + value: function createTextLayerBuilder(textLayerDiv, pageIndex, viewport) { + var enhanceTextSelection = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; + return new _text_layer_builder.TextLayerBuilder({ + textLayerDiv: textLayerDiv, + eventBus: this.eventBus, + pageIndex: pageIndex, + viewport: viewport, + findController: this.isInPresentationMode ? null : this.findController, + enhanceTextSelection: this.isInPresentationMode ? false : enhanceTextSelection + }); + } + }, { + key: "createAnnotationLayerBuilder", + value: function createAnnotationLayerBuilder(pageDiv, pdfPage) { + var imageResourcesPath = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : ''; + var renderInteractiveForms = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; + var l10n = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : _ui_utils.NullL10n; + return new _annotation_layer_builder.AnnotationLayerBuilder({ + pageDiv: pageDiv, + pdfPage: pdfPage, + imageResourcesPath: imageResourcesPath, + renderInteractiveForms: renderInteractiveForms, + linkService: this.linkService, + downloadManager: this.downloadManager, + l10n: l10n + }); + } + }, { + key: "getPagesOverview", + value: function getPagesOverview() { + var pagesOverview = this._pages.map(function (pageView) { + var viewport = pageView.pdfPage.getViewport({ + scale: 1 + }); + return { + width: viewport.width, + height: viewport.height, + rotation: viewport.rotation + }; + }); + + if (!this.enablePrintAutoRotate) { + return pagesOverview; + } + + var isFirstPagePortrait = (0, _ui_utils.isPortraitOrientation)(pagesOverview[0]); + return pagesOverview.map(function (size) { + if (isFirstPagePortrait === (0, _ui_utils.isPortraitOrientation)(size)) { + return size; + } + + return { + width: size.height, + height: size.width, + rotation: (size.rotation + 90) % 360 + }; + }); + } + }, { + key: "_updateScrollMode", + value: function _updateScrollMode() { + var pageNumber = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + var scrollMode = this._scrollMode, + viewer = this.viewer; + viewer.classList.toggle('scrollHorizontal', scrollMode === _ui_utils.ScrollMode.HORIZONTAL); + viewer.classList.toggle('scrollWrapped', scrollMode === _ui_utils.ScrollMode.WRAPPED); + + if (!this.pdfDocument || !pageNumber) { + return; + } + + if (this._currentScaleValue && isNaN(this._currentScaleValue)) { + this._setScale(this._currentScaleValue, true); + } + + this._setCurrentPageNumber(pageNumber, true); + + this.update(); + } + }, { + key: "_updateSpreadMode", + value: function _updateSpreadMode() { + var pageNumber = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + + if (!this.pdfDocument) { + return; + } + + var viewer = this.viewer, + pages = this._pages; + viewer.textContent = ''; + + if (this._spreadMode === _ui_utils.SpreadMode.NONE) { + for (var i = 0, iMax = pages.length; i < iMax; ++i) { + viewer.appendChild(pages[i].div); + } + } else { + var parity = this._spreadMode - 1; + var spread = null; + + for (var _i = 0, _iMax = pages.length; _i < _iMax; ++_i) { + if (spread === null) { + spread = document.createElement('div'); + spread.className = 'spread'; + viewer.appendChild(spread); + } else if (_i % 2 === parity) { + spread = spread.cloneNode(false); + viewer.appendChild(spread); + } + + spread.appendChild(pages[_i].div); + } + } + + if (!pageNumber) { + return; + } + + this._setCurrentPageNumber(pageNumber, true); + + this.update(); + } + }, { + key: "pagesCount", + get: function get() { + return this._pages.length; + } + }, { + key: "pageViewsReady", + get: function get() { + return this._pageViewsReady; + } + }, { + key: "currentPageNumber", + get: function get() { + return this._currentPageNumber; + }, + set: function set(val) { + if (!Number.isInteger(val)) { + throw new Error('Invalid page number.'); + } + + if (!this.pdfDocument) { + return; + } + + if (!this._setCurrentPageNumber(val, true)) { + console.error("".concat(this._name, ".currentPageNumber: \"").concat(val, "\" is not a valid page.")); + } + } + }, { + key: "currentPageLabel", + get: function get() { + return this._pageLabels && this._pageLabels[this._currentPageNumber - 1]; + }, + set: function set(val) { + if (!this.pdfDocument) { + return; + } + + var page = val | 0; + + if (this._pageLabels) { + var i = this._pageLabels.indexOf(val); + + if (i >= 0) { + page = i + 1; + } + } + + if (!this._setCurrentPageNumber(page, true)) { + console.error("".concat(this._name, ".currentPageLabel: \"").concat(val, "\" is not a valid page.")); + } + } + }, { + key: "currentScale", + get: function get() { + return this._currentScale !== _ui_utils.UNKNOWN_SCALE ? this._currentScale : _ui_utils.DEFAULT_SCALE; + }, + set: function set(val) { + if (isNaN(val)) { + throw new Error('Invalid numeric scale.'); + } + + if (!this.pdfDocument) { + return; + } + + this._setScale(val, false); + } + }, { + key: "currentScaleValue", + get: function get() { + return this._currentScaleValue; + }, + set: function set(val) { + if (!this.pdfDocument) { + return; + } + + this._setScale(val, false); + } + }, { + key: "pagesRotation", + get: function get() { + return this._pagesRotation; + }, + set: function set(rotation) { + if (!(0, _ui_utils.isValidRotation)(rotation)) { + throw new Error('Invalid pages rotation angle.'); + } + + if (!this.pdfDocument) { + return; + } + + if (this._pagesRotation === rotation) { + return; + } + + this._pagesRotation = rotation; + var pageNumber = this._currentPageNumber; + + for (var i = 0, ii = this._pages.length; i < ii; i++) { + var pageView = this._pages[i]; + pageView.update(pageView.scale, rotation); + } + + if (this._currentScaleValue) { + this._setScale(this._currentScaleValue, true); + } + + this.eventBus.dispatch('rotationchanging', { + source: this, + pagesRotation: rotation, + pageNumber: pageNumber + }); + + if (this.defaultRenderingQueue) { + this.update(); + } + } + }, { + key: "_setDocumentViewerElement", + get: function get() { + throw new Error('Not implemented: _setDocumentViewerElement'); + } + }, { + key: "_isScrollModeHorizontal", + get: function get() { + return this.isInPresentationMode ? false : this._scrollMode === _ui_utils.ScrollMode.HORIZONTAL; + } + }, { + key: "isInPresentationMode", + get: function get() { + return this.presentationModeState === _ui_utils.PresentationModeState.FULLSCREEN; + } + }, { + key: "isChangingPresentationMode", + get: function get() { + return this.presentationModeState === _ui_utils.PresentationModeState.CHANGING; + } + }, { + key: "isHorizontalScrollbarEnabled", + get: function get() { + return this.isInPresentationMode ? false : this.container.scrollWidth > this.container.clientWidth; + } + }, { + key: "isVerticalScrollbarEnabled", + get: function get() { + return this.isInPresentationMode ? false : this.container.scrollHeight > this.container.clientHeight; + } + }, { + key: "hasEqualPageSizes", + get: function get() { + var firstPageView = this._pages[0]; + + for (var i = 1, ii = this._pages.length; i < ii; ++i) { + var pageView = this._pages[i]; + + if (pageView.width !== firstPageView.width || pageView.height !== firstPageView.height) { + return false; + } + } + + return true; + } + }, { + key: "scrollMode", + get: function get() { + return this._scrollMode; + }, + set: function set(mode) { + if (this._scrollMode === mode) { + return; + } + + if (!(0, _ui_utils.isValidScrollMode)(mode)) { + throw new Error("Invalid scroll mode: ".concat(mode)); + } + + this._scrollMode = mode; + this.eventBus.dispatch('scrollmodechanged', { + source: this, + mode: mode + }); + + this._updateScrollMode(this._currentPageNumber); + } + }, { + key: "spreadMode", + get: function get() { + return this._spreadMode; + }, + set: function set(mode) { + if (this._spreadMode === mode) { + return; + } + + if (!(0, _ui_utils.isValidSpreadMode)(mode)) { + throw new Error("Invalid spread mode: ".concat(mode)); + } + + this._spreadMode = mode; + this.eventBus.dispatch('spreadmodechanged', { + source: this, + mode: mode + }); + + this._updateSpreadMode(this._currentPageNumber); + } + }]); + + return BaseViewer; +}(); + +exports.BaseViewer = BaseViewer; + +/***/ }), +/* 30 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.DefaultAnnotationLayerFactory = exports.AnnotationLayerBuilder = void 0; + +var _pdfjsLib = __webpack_require__(7); + +var _ui_utils = __webpack_require__(6); + +var _pdf_link_service = __webpack_require__(22); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var AnnotationLayerBuilder = +/*#__PURE__*/ +function () { + function AnnotationLayerBuilder(_ref) { + var pageDiv = _ref.pageDiv, + pdfPage = _ref.pdfPage, + linkService = _ref.linkService, + downloadManager = _ref.downloadManager, + _ref$imageResourcesPa = _ref.imageResourcesPath, + imageResourcesPath = _ref$imageResourcesPa === void 0 ? '' : _ref$imageResourcesPa, + _ref$renderInteractiv = _ref.renderInteractiveForms, + renderInteractiveForms = _ref$renderInteractiv === void 0 ? false : _ref$renderInteractiv, + _ref$l10n = _ref.l10n, + l10n = _ref$l10n === void 0 ? _ui_utils.NullL10n : _ref$l10n; + + _classCallCheck(this, AnnotationLayerBuilder); + + this.pageDiv = pageDiv; + this.pdfPage = pdfPage; + this.linkService = linkService; + this.downloadManager = downloadManager; + this.imageResourcesPath = imageResourcesPath; + this.renderInteractiveForms = renderInteractiveForms; + this.l10n = l10n; + this.div = null; + this._cancelled = false; + } + + _createClass(AnnotationLayerBuilder, [{ + key: "render", + value: function render(viewport) { + var _this = this; + + var intent = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'display'; + this.pdfPage.getAnnotations({ + intent: intent + }).then(function (annotations) { + if (_this._cancelled) { + return; + } + + var parameters = { + viewport: viewport.clone({ + dontFlip: true + }), + div: _this.div, + annotations: annotations, + page: _this.pdfPage, + imageResourcesPath: _this.imageResourcesPath, + renderInteractiveForms: _this.renderInteractiveForms, + linkService: _this.linkService, + downloadManager: _this.downloadManager + }; + + if (_this.div) { + _pdfjsLib.AnnotationLayer.update(parameters); + } else { + if (annotations.length === 0) { + return; + } + + _this.div = document.createElement('div'); + _this.div.className = 'annotationLayer'; + + _this.pageDiv.appendChild(_this.div); + + parameters.div = _this.div; + + _pdfjsLib.AnnotationLayer.render(parameters); + + _this.l10n.translate(_this.div); + } + }); + } + }, { + key: "cancel", + value: function cancel() { + this._cancelled = true; + } + }, { + key: "hide", + value: function hide() { + if (!this.div) { + return; + } + + this.div.setAttribute('hidden', 'true'); + } + }]); + + return AnnotationLayerBuilder; +}(); + +exports.AnnotationLayerBuilder = AnnotationLayerBuilder; + +var DefaultAnnotationLayerFactory = +/*#__PURE__*/ +function () { + function DefaultAnnotationLayerFactory() { + _classCallCheck(this, DefaultAnnotationLayerFactory); + } + + _createClass(DefaultAnnotationLayerFactory, [{ + key: "createAnnotationLayerBuilder", + value: function createAnnotationLayerBuilder(pageDiv, pdfPage) { + var imageResourcesPath = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : ''; + var renderInteractiveForms = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; + var l10n = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : _ui_utils.NullL10n; + return new AnnotationLayerBuilder({ + pageDiv: pageDiv, + pdfPage: pdfPage, + imageResourcesPath: imageResourcesPath, + renderInteractiveForms: renderInteractiveForms, + linkService: new _pdf_link_service.SimpleLinkService(), + l10n: l10n + }); + } + }]); + + return DefaultAnnotationLayerFactory; +}(); + +exports.DefaultAnnotationLayerFactory = DefaultAnnotationLayerFactory; + +/***/ }), +/* 31 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFPageView = void 0; + +var _regenerator = _interopRequireDefault(__webpack_require__(2)); + +var _ui_utils = __webpack_require__(6); + +var _pdfjsLib = __webpack_require__(7); + +var _pdf_rendering_queue = __webpack_require__(10); + +var _viewer_compatibility = __webpack_require__(13); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } + +function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var MAX_CANVAS_PIXELS = _viewer_compatibility.viewerCompatibilityParams.maxCanvasPixels || 16777216; + +var PDFPageView = +/*#__PURE__*/ +function () { + function PDFPageView(options) { + _classCallCheck(this, PDFPageView); + + var container = options.container; + var defaultViewport = options.defaultViewport; + this.id = options.id; + this.renderingId = 'page' + this.id; + this.pdfPage = null; + this.pageLabel = null; + this.rotation = 0; + this.scale = options.scale || _ui_utils.DEFAULT_SCALE; + this.viewport = defaultViewport; + this.pdfPageRotate = defaultViewport.rotation; + this.hasRestrictedScaling = false; + this.textLayerMode = Number.isInteger(options.textLayerMode) ? options.textLayerMode : _ui_utils.TextLayerMode.ENABLE; + this.imageResourcesPath = options.imageResourcesPath || ''; + this.renderInteractiveForms = options.renderInteractiveForms || false; + this.useOnlyCssZoom = options.useOnlyCssZoom || false; + this.maxCanvasPixels = options.maxCanvasPixels || MAX_CANVAS_PIXELS; + this.eventBus = options.eventBus || (0, _ui_utils.getGlobalEventBus)(); + this.renderingQueue = options.renderingQueue; + this.textLayerFactory = options.textLayerFactory; + this.annotationLayerFactory = options.annotationLayerFactory; + this.renderer = options.renderer || _ui_utils.RendererType.CANVAS; + this.enableWebGL = options.enableWebGL || false; + this.l10n = options.l10n || _ui_utils.NullL10n; + this.paintTask = null; + this.paintedViewportMap = new WeakMap(); + this.renderingState = _pdf_rendering_queue.RenderingStates.INITIAL; + this.resume = null; + this.error = null; + this.onBeforeDraw = null; + this.onAfterDraw = null; + this.annotationLayer = null; + this.textLayer = null; + this.zoomLayer = null; + var div = document.createElement('div'); + div.className = 'page'; + div.style.width = Math.floor(this.viewport.width) + 'px'; + div.style.height = Math.floor(this.viewport.height) + 'px'; + div.setAttribute('data-page-number', this.id); + this.div = div; + container.appendChild(div); + } + + _createClass(PDFPageView, [{ + key: "setPdfPage", + value: function setPdfPage(pdfPage) { + this.pdfPage = pdfPage; + this.pdfPageRotate = pdfPage.rotate; + var totalRotation = (this.rotation + this.pdfPageRotate) % 360; + this.viewport = pdfPage.getViewport({ + scale: this.scale * _ui_utils.CSS_UNITS, + rotation: totalRotation + }); + this.stats = pdfPage.stats; + this.reset(); + } + }, { + key: "destroy", + value: function destroy() { + this.reset(); + + if (this.pdfPage) { + this.pdfPage.cleanup(); + } + } + }, { + key: "_resetZoomLayer", + value: function _resetZoomLayer() { + var removeFromDOM = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + + if (!this.zoomLayer) { + return; + } + + var zoomLayerCanvas = this.zoomLayer.firstChild; + this.paintedViewportMap.delete(zoomLayerCanvas); + zoomLayerCanvas.width = 0; + zoomLayerCanvas.height = 0; + + if (removeFromDOM) { + this.zoomLayer.remove(); + } + + this.zoomLayer = null; + } + }, { + key: "reset", + value: function reset() { + var keepZoomLayer = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + var keepAnnotations = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + this.cancelRendering(keepAnnotations); + var div = this.div; + div.style.width = Math.floor(this.viewport.width) + 'px'; + div.style.height = Math.floor(this.viewport.height) + 'px'; + var childNodes = div.childNodes; + var currentZoomLayerNode = keepZoomLayer && this.zoomLayer || null; + var currentAnnotationNode = keepAnnotations && this.annotationLayer && this.annotationLayer.div || null; + + for (var i = childNodes.length - 1; i >= 0; i--) { + var node = childNodes[i]; + + if (currentZoomLayerNode === node || currentAnnotationNode === node) { + continue; + } + + div.removeChild(node); + } + + div.removeAttribute('data-loaded'); + + if (currentAnnotationNode) { + this.annotationLayer.hide(); + } else if (this.annotationLayer) { + this.annotationLayer.cancel(); + this.annotationLayer = null; + } + + if (!currentZoomLayerNode) { + if (this.canvas) { + this.paintedViewportMap.delete(this.canvas); + this.canvas.width = 0; + this.canvas.height = 0; + delete this.canvas; + } + + this._resetZoomLayer(); + } + + if (this.svg) { + this.paintedViewportMap.delete(this.svg); + delete this.svg; + } + + this.loadingIconDiv = document.createElement('div'); + this.loadingIconDiv.className = 'loadingIcon'; + div.appendChild(this.loadingIconDiv); + } + }, { + key: "update", + value: function update(scale, rotation) { + this.scale = scale || this.scale; + + if (typeof rotation !== 'undefined') { + this.rotation = rotation; + } + + var totalRotation = (this.rotation + this.pdfPageRotate) % 360; + this.viewport = this.viewport.clone({ + scale: this.scale * _ui_utils.CSS_UNITS, + rotation: totalRotation + }); + + if (this.svg) { + this.cssTransform(this.svg, true); + this.eventBus.dispatch('pagerendered', { + source: this, + pageNumber: this.id, + cssTransform: true + }); + return; + } + + var isScalingRestricted = false; + + if (this.canvas && this.maxCanvasPixels > 0) { + var outputScale = this.outputScale; + + if ((Math.floor(this.viewport.width) * outputScale.sx | 0) * (Math.floor(this.viewport.height) * outputScale.sy | 0) > this.maxCanvasPixels) { + isScalingRestricted = true; + } + } + + if (this.canvas) { + if (this.useOnlyCssZoom || this.hasRestrictedScaling && isScalingRestricted) { + this.cssTransform(this.canvas, true); + this.eventBus.dispatch('pagerendered', { + source: this, + pageNumber: this.id, + cssTransform: true + }); + return; + } + + if (!this.zoomLayer && !this.canvas.hasAttribute('hidden')) { + this.zoomLayer = this.canvas.parentNode; + this.zoomLayer.style.position = 'absolute'; + } + } + + if (this.zoomLayer) { + this.cssTransform(this.zoomLayer.firstChild); + } + + this.reset(true, true); + } + }, { + key: "cancelRendering", + value: function cancelRendering() { + var keepAnnotations = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + var renderingState = this.renderingState; + + if (this.paintTask) { + this.paintTask.cancel(); + this.paintTask = null; + } + + this.renderingState = _pdf_rendering_queue.RenderingStates.INITIAL; + this.resume = null; + + if (this.textLayer) { + this.textLayer.cancel(); + this.textLayer = null; + } + + if (!keepAnnotations && this.annotationLayer) { + this.annotationLayer.cancel(); + this.annotationLayer = null; + } + + if (renderingState !== _pdf_rendering_queue.RenderingStates.INITIAL) { + this.eventBus.dispatch('pagecancelled', { + source: this, + pageNumber: this.id, + renderingState: renderingState + }); + } + } + }, { + key: "cssTransform", + value: function cssTransform(target) { + var redrawAnnotations = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + var width = this.viewport.width; + var height = this.viewport.height; + var div = this.div; + target.style.width = target.parentNode.style.width = div.style.width = Math.floor(width) + 'px'; + target.style.height = target.parentNode.style.height = div.style.height = Math.floor(height) + 'px'; + var relativeRotation = this.viewport.rotation - this.paintedViewportMap.get(target).rotation; + var absRotation = Math.abs(relativeRotation); + var scaleX = 1, + scaleY = 1; + + if (absRotation === 90 || absRotation === 270) { + scaleX = height / width; + scaleY = width / height; + } + + var cssTransform = 'rotate(' + relativeRotation + 'deg) ' + 'scale(' + scaleX + ',' + scaleY + ')'; + target.style.transform = cssTransform; + + if (this.textLayer) { + var textLayerViewport = this.textLayer.viewport; + var textRelativeRotation = this.viewport.rotation - textLayerViewport.rotation; + var textAbsRotation = Math.abs(textRelativeRotation); + var scale = width / textLayerViewport.width; + + if (textAbsRotation === 90 || textAbsRotation === 270) { + scale = width / textLayerViewport.height; + } + + var textLayerDiv = this.textLayer.textLayerDiv; + var transX, transY; + + switch (textAbsRotation) { + case 0: + transX = transY = 0; + break; + + case 90: + transX = 0; + transY = '-' + textLayerDiv.style.height; + break; + + case 180: + transX = '-' + textLayerDiv.style.width; + transY = '-' + textLayerDiv.style.height; + break; + + case 270: + transX = '-' + textLayerDiv.style.width; + transY = 0; + break; + + default: + console.error('Bad rotation value.'); + break; + } + + textLayerDiv.style.transform = 'rotate(' + textAbsRotation + 'deg) ' + 'scale(' + scale + ', ' + scale + ') ' + 'translate(' + transX + ', ' + transY + ')'; + textLayerDiv.style.transformOrigin = '0% 0%'; + } + + if (redrawAnnotations && this.annotationLayer) { + this.annotationLayer.render(this.viewport, 'display'); + } + } + }, { + key: "getPagePoint", + value: function getPagePoint(x, y) { + return this.viewport.convertToPdfPoint(x, y); + } + }, { + key: "draw", + value: function draw() { + var _this = this; + + if (this.renderingState !== _pdf_rendering_queue.RenderingStates.INITIAL) { + console.error('Must be in new state before drawing'); + this.reset(); + } + + if (!this.pdfPage) { + this.renderingState = _pdf_rendering_queue.RenderingStates.FINISHED; + return Promise.reject(new Error('Page is not loaded')); + } + + this.renderingState = _pdf_rendering_queue.RenderingStates.RUNNING; + var pdfPage = this.pdfPage; + var div = this.div; + var canvasWrapper = document.createElement('div'); + canvasWrapper.style.width = div.style.width; + canvasWrapper.style.height = div.style.height; + canvasWrapper.classList.add('canvasWrapper'); + + if (this.annotationLayer && this.annotationLayer.div) { + div.insertBefore(canvasWrapper, this.annotationLayer.div); + } else { + div.appendChild(canvasWrapper); + } + + var textLayer = null; + + if (this.textLayerMode !== _ui_utils.TextLayerMode.DISABLE && this.textLayerFactory) { + var textLayerDiv = document.createElement('div'); + textLayerDiv.className = 'textLayer'; + textLayerDiv.style.width = canvasWrapper.style.width; + textLayerDiv.style.height = canvasWrapper.style.height; + + if (this.annotationLayer && this.annotationLayer.div) { + div.insertBefore(textLayerDiv, this.annotationLayer.div); + } else { + div.appendChild(textLayerDiv); + } + + textLayer = this.textLayerFactory.createTextLayerBuilder(textLayerDiv, this.id - 1, this.viewport, this.textLayerMode === _ui_utils.TextLayerMode.ENABLE_ENHANCE); + } + + this.textLayer = textLayer; + var renderContinueCallback = null; + + if (this.renderingQueue) { + renderContinueCallback = function renderContinueCallback(cont) { + if (!_this.renderingQueue.isHighestPriority(_this)) { + _this.renderingState = _pdf_rendering_queue.RenderingStates.PAUSED; + + _this.resume = function () { + _this.renderingState = _pdf_rendering_queue.RenderingStates.RUNNING; + cont(); + }; + + return; + } + + cont(); + }; + } + + var finishPaintTask = + /*#__PURE__*/ + function () { + var _ref = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee(error) { + return _regenerator.default.wrap(function _callee$(_context) { + while (1) { + switch (_context.prev = _context.next) { + case 0: + if (paintTask === _this.paintTask) { + _this.paintTask = null; + } + + if (!(error instanceof _pdfjsLib.RenderingCancelledException)) { + _context.next = 4; + break; + } + + _this.error = null; + return _context.abrupt("return"); + + case 4: + _this.renderingState = _pdf_rendering_queue.RenderingStates.FINISHED; + + if (_this.loadingIconDiv) { + div.removeChild(_this.loadingIconDiv); + delete _this.loadingIconDiv; + } + + _this._resetZoomLayer(true); + + _this.error = error; + _this.stats = pdfPage.stats; + + if (_this.onAfterDraw) { + _this.onAfterDraw(); + } + + _this.eventBus.dispatch('pagerendered', { + source: _this, + pageNumber: _this.id, + cssTransform: false + }); + + if (!error) { + _context.next = 13; + break; + } + + throw error; + + case 13: + case "end": + return _context.stop(); + } + } + }, _callee, this); + })); + + return function finishPaintTask(_x) { + return _ref.apply(this, arguments); + }; + }(); + + var paintTask = this.renderer === _ui_utils.RendererType.SVG ? this.paintOnSvg(canvasWrapper) : this.paintOnCanvas(canvasWrapper); + paintTask.onRenderContinue = renderContinueCallback; + this.paintTask = paintTask; + var resultPromise = paintTask.promise.then(function () { + return finishPaintTask(null).then(function () { + if (textLayer) { + var readableStream = pdfPage.streamTextContent({ + normalizeWhitespace: true + }); + textLayer.setTextContentStream(readableStream); + textLayer.render(); + } + }); + }, function (reason) { + return finishPaintTask(reason); + }); + + if (this.annotationLayerFactory) { + if (!this.annotationLayer) { + this.annotationLayer = this.annotationLayerFactory.createAnnotationLayerBuilder(div, pdfPage, this.imageResourcesPath, this.renderInteractiveForms, this.l10n); + } + + this.annotationLayer.render(this.viewport, 'display'); + } + + div.setAttribute('data-loaded', true); + + if (this.onBeforeDraw) { + this.onBeforeDraw(); + } + + return resultPromise; + } + }, { + key: "paintOnCanvas", + value: function paintOnCanvas(canvasWrapper) { + var renderCapability = (0, _pdfjsLib.createPromiseCapability)(); + var result = { + promise: renderCapability.promise, + onRenderContinue: function onRenderContinue(cont) { + cont(); + }, + cancel: function cancel() { + renderTask.cancel(); + } + }; + var viewport = this.viewport; + var canvas = document.createElement('canvas'); + canvas.id = this.renderingId; + canvas.setAttribute('hidden', 'hidden'); + var isCanvasHidden = true; + + var showCanvas = function showCanvas() { + if (isCanvasHidden) { + canvas.removeAttribute('hidden'); + isCanvasHidden = false; + } + }; + + canvasWrapper.appendChild(canvas); + this.canvas = canvas; + canvas.mozOpaque = true; + var ctx = canvas.getContext('2d', { + alpha: false + }); + var outputScale = (0, _ui_utils.getOutputScale)(ctx); + this.outputScale = outputScale; + + if (this.useOnlyCssZoom) { + var actualSizeViewport = viewport.clone({ + scale: _ui_utils.CSS_UNITS + }); + outputScale.sx *= actualSizeViewport.width / viewport.width; + outputScale.sy *= actualSizeViewport.height / viewport.height; + outputScale.scaled = true; + } + + if (this.maxCanvasPixels > 0) { + var pixelsInViewport = viewport.width * viewport.height; + var maxScale = Math.sqrt(this.maxCanvasPixels / pixelsInViewport); + + if (outputScale.sx > maxScale || outputScale.sy > maxScale) { + outputScale.sx = maxScale; + outputScale.sy = maxScale; + outputScale.scaled = true; + this.hasRestrictedScaling = true; + } else { + this.hasRestrictedScaling = false; + } + } + + var sfx = (0, _ui_utils.approximateFraction)(outputScale.sx); + var sfy = (0, _ui_utils.approximateFraction)(outputScale.sy); + canvas.width = (0, _ui_utils.roundToDivide)(viewport.width * outputScale.sx, sfx[0]); + canvas.height = (0, _ui_utils.roundToDivide)(viewport.height * outputScale.sy, sfy[0]); + canvas.style.width = (0, _ui_utils.roundToDivide)(viewport.width, sfx[1]) + 'px'; + canvas.style.height = (0, _ui_utils.roundToDivide)(viewport.height, sfy[1]) + 'px'; + this.paintedViewportMap.set(canvas, viewport); + var transform = !outputScale.scaled ? null : [outputScale.sx, 0, 0, outputScale.sy, 0, 0]; + var renderContext = { + canvasContext: ctx, + transform: transform, + viewport: this.viewport, + enableWebGL: this.enableWebGL, + renderInteractiveForms: this.renderInteractiveForms + }; + var renderTask = this.pdfPage.render(renderContext); + + renderTask.onContinue = function (cont) { + showCanvas(); + + if (result.onRenderContinue) { + result.onRenderContinue(cont); + } else { + cont(); + } + }; + + renderTask.promise.then(function () { + showCanvas(); + renderCapability.resolve(undefined); + }, function (error) { + showCanvas(); + renderCapability.reject(error); + }); + return result; + } + }, { + key: "paintOnSvg", + value: function paintOnSvg(wrapper) { + var _this2 = this; + + var cancelled = false; + + var ensureNotCancelled = function ensureNotCancelled() { + if (cancelled) { + throw new _pdfjsLib.RenderingCancelledException('Rendering cancelled, page ' + _this2.id, 'svg'); + } + }; + + var pdfPage = this.pdfPage; + var actualSizeViewport = this.viewport.clone({ + scale: _ui_utils.CSS_UNITS + }); + var promise = pdfPage.getOperatorList().then(function (opList) { + ensureNotCancelled(); + var svgGfx = new _pdfjsLib.SVGGraphics(pdfPage.commonObjs, pdfPage.objs); + return svgGfx.getSVG(opList, actualSizeViewport).then(function (svg) { + ensureNotCancelled(); + _this2.svg = svg; + + _this2.paintedViewportMap.set(svg, actualSizeViewport); + + svg.style.width = wrapper.style.width; + svg.style.height = wrapper.style.height; + _this2.renderingState = _pdf_rendering_queue.RenderingStates.FINISHED; + wrapper.appendChild(svg); + }); + }); + return { + promise: promise, + onRenderContinue: function onRenderContinue(cont) { + cont(); + }, + cancel: function cancel() { + cancelled = true; + } + }; + } + }, { + key: "setPageLabel", + value: function setPageLabel(label) { + this.pageLabel = typeof label === 'string' ? label : null; + + if (this.pageLabel !== null) { + this.div.setAttribute('data-page-label', this.pageLabel); + } else { + this.div.removeAttribute('data-page-label'); + } + } + }, { + key: "width", + get: function get() { + return this.viewport.width; + } + }, { + key: "height", + get: function get() { + return this.viewport.height; + } + }]); + + return PDFPageView; +}(); + +exports.PDFPageView = PDFPageView; + +/***/ }), +/* 32 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.DefaultTextLayerFactory = exports.TextLayerBuilder = void 0; + +var _ui_utils = __webpack_require__(6); + +var _pdfjsLib = __webpack_require__(7); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var EXPAND_DIVS_TIMEOUT = 300; + +var TextLayerBuilder = +/*#__PURE__*/ +function () { + function TextLayerBuilder(_ref) { + var textLayerDiv = _ref.textLayerDiv, + eventBus = _ref.eventBus, + pageIndex = _ref.pageIndex, + viewport = _ref.viewport, + _ref$findController = _ref.findController, + findController = _ref$findController === void 0 ? null : _ref$findController, + _ref$enhanceTextSelec = _ref.enhanceTextSelection, + enhanceTextSelection = _ref$enhanceTextSelec === void 0 ? false : _ref$enhanceTextSelec; + + _classCallCheck(this, TextLayerBuilder); + + this.textLayerDiv = textLayerDiv; + this.eventBus = eventBus || (0, _ui_utils.getGlobalEventBus)(); + this.textContent = null; + this.textContentItemsStr = []; + this.textContentStream = null; + this.renderingDone = false; + this.pageIdx = pageIndex; + this.pageNumber = this.pageIdx + 1; + this.matches = []; + this.viewport = viewport; + this.textDivs = []; + this.findController = findController; + this.textLayerRenderTask = null; + this.enhanceTextSelection = enhanceTextSelection; + this._boundEvents = Object.create(null); + + this._bindEvents(); + + this._bindMouse(); + } + + _createClass(TextLayerBuilder, [{ + key: "_finishRendering", + value: function _finishRendering() { + this.renderingDone = true; + + if (!this.enhanceTextSelection) { + var endOfContent = document.createElement('div'); + endOfContent.className = 'endOfContent'; + this.textLayerDiv.appendChild(endOfContent); + } + + this.eventBus.dispatch('textlayerrendered', { + source: this, + pageNumber: this.pageNumber, + numTextDivs: this.textDivs.length + }); + } + }, { + key: "render", + value: function render() { + var _this = this; + + var timeout = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + + if (!(this.textContent || this.textContentStream) || this.renderingDone) { + return; + } + + this.cancel(); + this.textDivs = []; + var textLayerFrag = document.createDocumentFragment(); + this.textLayerRenderTask = (0, _pdfjsLib.renderTextLayer)({ + textContent: this.textContent, + textContentStream: this.textContentStream, + container: textLayerFrag, + viewport: this.viewport, + textDivs: this.textDivs, + textContentItemsStr: this.textContentItemsStr, + timeout: timeout, + enhanceTextSelection: this.enhanceTextSelection + }); + this.textLayerRenderTask.promise.then(function () { + _this.textLayerDiv.appendChild(textLayerFrag); + + _this._finishRendering(); + + _this._updateMatches(); + }, function (reason) {}); + } + }, { + key: "cancel", + value: function cancel() { + if (this.textLayerRenderTask) { + this.textLayerRenderTask.cancel(); + this.textLayerRenderTask = null; + } + } + }, { + key: "setTextContentStream", + value: function setTextContentStream(readableStream) { + this.cancel(); + this.textContentStream = readableStream; + } + }, { + key: "setTextContent", + value: function setTextContent(textContent) { + this.cancel(); + this.textContent = textContent; + } + }, { + key: "_convertMatches", + value: function _convertMatches(matches, matchesLength) { + if (!matches) { + return []; + } + + var findController = this.findController, + textContentItemsStr = this.textContentItemsStr; + var i = 0, + iIndex = 0; + var end = textContentItemsStr.length - 1; + var queryLen = findController.state.query.length; + var result = []; + + for (var m = 0, mm = matches.length; m < mm; m++) { + var matchIdx = matches[m]; + + while (i !== end && matchIdx >= iIndex + textContentItemsStr[i].length) { + iIndex += textContentItemsStr[i].length; + i++; + } + + if (i === textContentItemsStr.length) { + console.error('Could not find a matching mapping'); + } + + var match = { + begin: { + divIdx: i, + offset: matchIdx - iIndex + } + }; + + if (matchesLength) { + matchIdx += matchesLength[m]; + } else { + matchIdx += queryLen; + } + + while (i !== end && matchIdx > iIndex + textContentItemsStr[i].length) { + iIndex += textContentItemsStr[i].length; + i++; + } + + match.end = { + divIdx: i, + offset: matchIdx - iIndex + }; + result.push(match); + } + + return result; + } + }, { + key: "_renderMatches", + value: function _renderMatches(matches) { + if (matches.length === 0) { + return; + } + + var findController = this.findController, + pageIdx = this.pageIdx, + textContentItemsStr = this.textContentItemsStr, + textDivs = this.textDivs; + var isSelectedPage = pageIdx === findController.selected.pageIdx; + var selectedMatchIdx = findController.selected.matchIdx; + var highlightAll = findController.state.highlightAll; + var prevEnd = null; + var infinity = { + divIdx: -1, + offset: undefined + }; + + function beginText(begin, className) { + var divIdx = begin.divIdx; + textDivs[divIdx].textContent = ''; + appendTextToDiv(divIdx, 0, begin.offset, className); + } + + function appendTextToDiv(divIdx, fromOffset, toOffset, className) { + var div = textDivs[divIdx]; + var content = textContentItemsStr[divIdx].substring(fromOffset, toOffset); + var node = document.createTextNode(content); + + if (className) { + var span = document.createElement('span'); + span.className = className; + span.appendChild(node); + div.appendChild(span); + return; + } + + div.appendChild(node); + } + + var i0 = selectedMatchIdx, + i1 = i0 + 1; + + if (highlightAll) { + i0 = 0; + i1 = matches.length; + } else if (!isSelectedPage) { + return; + } + + for (var i = i0; i < i1; i++) { + var match = matches[i]; + var begin = match.begin; + var end = match.end; + var isSelected = isSelectedPage && i === selectedMatchIdx; + var highlightSuffix = isSelected ? ' selected' : ''; + + if (isSelected) { + findController.scrollMatchIntoView({ + element: textDivs[begin.divIdx], + pageIndex: pageIdx, + matchIndex: selectedMatchIdx + }); + } + + if (!prevEnd || begin.divIdx !== prevEnd.divIdx) { + if (prevEnd !== null) { + appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset); + } + + beginText(begin); + } else { + appendTextToDiv(prevEnd.divIdx, prevEnd.offset, begin.offset); + } + + if (begin.divIdx === end.divIdx) { + appendTextToDiv(begin.divIdx, begin.offset, end.offset, 'highlight' + highlightSuffix); + } else { + appendTextToDiv(begin.divIdx, begin.offset, infinity.offset, 'highlight begin' + highlightSuffix); + + for (var n0 = begin.divIdx + 1, n1 = end.divIdx; n0 < n1; n0++) { + textDivs[n0].className = 'highlight middle' + highlightSuffix; + } + + beginText(end, 'highlight end' + highlightSuffix); + } + + prevEnd = end; + } + + if (prevEnd) { + appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset); + } + } + }, { + key: "_updateMatches", + value: function _updateMatches() { + if (!this.renderingDone) { + return; + } + + var findController = this.findController, + matches = this.matches, + pageIdx = this.pageIdx, + textContentItemsStr = this.textContentItemsStr, + textDivs = this.textDivs; + var clearedUntilDivIdx = -1; + + for (var i = 0, ii = matches.length; i < ii; i++) { + var match = matches[i]; + var begin = Math.max(clearedUntilDivIdx, match.begin.divIdx); + + for (var n = begin, end = match.end.divIdx; n <= end; n++) { + var div = textDivs[n]; + div.textContent = textContentItemsStr[n]; + div.className = ''; + } + + clearedUntilDivIdx = match.end.divIdx + 1; + } + + if (!findController || !findController.highlightMatches) { + return; + } + + var pageMatches = findController.pageMatches[pageIdx] || null; + var pageMatchesLength = findController.pageMatchesLength[pageIdx] || null; + this.matches = this._convertMatches(pageMatches, pageMatchesLength); + + this._renderMatches(this.matches); + } + }, { + key: "_bindEvents", + value: function _bindEvents() { + var _this2 = this; + + var eventBus = this.eventBus, + _boundEvents = this._boundEvents; + + _boundEvents.pageCancelled = function (evt) { + if (evt.pageNumber !== _this2.pageNumber) { + return; + } + + if (_this2.textLayerRenderTask) { + console.error('TextLayerBuilder._bindEvents: `this.cancel()` should ' + 'have been called when the page was reset, or rendering cancelled.'); + return; + } + + for (var name in _boundEvents) { + eventBus.off(name.toLowerCase(), _boundEvents[name]); + delete _boundEvents[name]; + } + }; + + _boundEvents.updateTextLayerMatches = function (evt) { + if (evt.pageIndex !== _this2.pageIdx && evt.pageIndex !== -1) { + return; + } + + _this2._updateMatches(); + }; + + eventBus.on('pagecancelled', _boundEvents.pageCancelled); + eventBus.on('updatetextlayermatches', _boundEvents.updateTextLayerMatches); + } + }, { + key: "_bindMouse", + value: function _bindMouse() { + var _this3 = this; + + var div = this.textLayerDiv; + var expandDivsTimer = null; + div.addEventListener('mousedown', function (evt) { + if (_this3.enhanceTextSelection && _this3.textLayerRenderTask) { + _this3.textLayerRenderTask.expandTextDivs(true); + + if (expandDivsTimer) { + clearTimeout(expandDivsTimer); + expandDivsTimer = null; + } + + return; + } + + var end = div.querySelector('.endOfContent'); + + if (!end) { + return; + } + + var adjustTop = evt.target !== div; + adjustTop = adjustTop && window.getComputedStyle(end).getPropertyValue('-moz-user-select') !== 'none'; + + if (adjustTop) { + var divBounds = div.getBoundingClientRect(); + var r = Math.max(0, (evt.pageY - divBounds.top) / divBounds.height); + end.style.top = (r * 100).toFixed(2) + '%'; + } + + end.classList.add('active'); + }); + div.addEventListener('mouseup', function () { + if (_this3.enhanceTextSelection && _this3.textLayerRenderTask) { + expandDivsTimer = setTimeout(function () { + if (_this3.textLayerRenderTask) { + _this3.textLayerRenderTask.expandTextDivs(false); + } + + expandDivsTimer = null; + }, EXPAND_DIVS_TIMEOUT); + return; + } + + var end = div.querySelector('.endOfContent'); + + if (!end) { + return; + } + + end.style.top = ''; + end.classList.remove('active'); + }); + } + }]); + + return TextLayerBuilder; +}(); + +exports.TextLayerBuilder = TextLayerBuilder; + +var DefaultTextLayerFactory = +/*#__PURE__*/ +function () { + function DefaultTextLayerFactory() { + _classCallCheck(this, DefaultTextLayerFactory); + } + + _createClass(DefaultTextLayerFactory, [{ + key: "createTextLayerBuilder", + value: function createTextLayerBuilder(textLayerDiv, pageIndex, viewport) { + var enhanceTextSelection = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; + return new TextLayerBuilder({ + textLayerDiv: textLayerDiv, + pageIndex: pageIndex, + viewport: viewport, + enhanceTextSelection: enhanceTextSelection + }); + } + }]); + + return DefaultTextLayerFactory; +}(); + +exports.DefaultTextLayerFactory = DefaultTextLayerFactory; + +/***/ }), +/* 33 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SecondaryToolbar = void 0; + +var _ui_utils = __webpack_require__(6); + +var _pdf_cursor_tools = __webpack_require__(8); + +var _pdf_single_page_viewer = __webpack_require__(34); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var SecondaryToolbar = +/*#__PURE__*/ +function () { + function SecondaryToolbar(options, mainContainer, eventBus) { + var _this = this; + + _classCallCheck(this, SecondaryToolbar); + + this.toolbar = options.toolbar; + this.toggleButton = options.toggleButton; + this.toolbarButtonContainer = options.toolbarButtonContainer; + this.buttons = [{ + element: options.presentationModeButton, + eventName: 'presentationmode', + close: true + }, { + element: options.openFileButton, + eventName: 'openfile', + close: true + }, { + element: options.printButton, + eventName: 'print', + close: true + }, { + element: options.downloadButton, + eventName: 'download', + close: true + }, { + element: options.viewBookmarkButton, + eventName: null, + close: true + }, { + element: options.firstPageButton, + eventName: 'firstpage', + close: true + }, { + element: options.lastPageButton, + eventName: 'lastpage', + close: true + }, { + element: options.pageRotateCwButton, + eventName: 'rotatecw', + close: false + }, { + element: options.pageRotateCcwButton, + eventName: 'rotateccw', + close: false + }, { + element: options.cursorSelectToolButton, + eventName: 'switchcursortool', + eventDetails: { + tool: _pdf_cursor_tools.CursorTool.SELECT + }, + close: true + }, { + element: options.cursorHandToolButton, + eventName: 'switchcursortool', + eventDetails: { + tool: _pdf_cursor_tools.CursorTool.HAND + }, + close: true + }, { + element: options.scrollVerticalButton, + eventName: 'switchscrollmode', + eventDetails: { + mode: _ui_utils.ScrollMode.VERTICAL + }, + close: true + }, { + element: options.scrollHorizontalButton, + eventName: 'switchscrollmode', + eventDetails: { + mode: _ui_utils.ScrollMode.HORIZONTAL + }, + close: true + }, { + element: options.scrollWrappedButton, + eventName: 'switchscrollmode', + eventDetails: { + mode: _ui_utils.ScrollMode.WRAPPED + }, + close: true + }, { + element: options.spreadNoneButton, + eventName: 'switchspreadmode', + eventDetails: { + mode: _ui_utils.SpreadMode.NONE + }, + close: true + }, { + element: options.spreadOddButton, + eventName: 'switchspreadmode', + eventDetails: { + mode: _ui_utils.SpreadMode.ODD + }, + close: true + }, { + element: options.spreadEvenButton, + eventName: 'switchspreadmode', + eventDetails: { + mode: _ui_utils.SpreadMode.EVEN + }, + close: true + }, { + element: options.documentPropertiesButton, + eventName: 'documentproperties', + close: true + }]; + this.items = { + firstPage: options.firstPageButton, + lastPage: options.lastPageButton, + pageRotateCw: options.pageRotateCwButton, + pageRotateCcw: options.pageRotateCcwButton + }; + this.mainContainer = mainContainer; + this.eventBus = eventBus; + this.opened = false; + this.containerHeight = null; + this.previousContainerHeight = null; + this.reset(); + + this._bindClickListeners(); + + this._bindCursorToolsListener(options); + + this._bindScrollModeListener(options); + + this._bindSpreadModeListener(options); + + this.eventBus.on('resize', this._setMaxHeight.bind(this)); + this.eventBus.on('baseviewerinit', function (evt) { + if (evt.source instanceof _pdf_single_page_viewer.PDFSinglePageViewer) { + _this.toolbarButtonContainer.classList.add('hiddenScrollModeButtons', 'hiddenSpreadModeButtons'); + } else { + _this.toolbarButtonContainer.classList.remove('hiddenScrollModeButtons', 'hiddenSpreadModeButtons'); + } + }); + } + + _createClass(SecondaryToolbar, [{ + key: "setPageNumber", + value: function setPageNumber(pageNumber) { + this.pageNumber = pageNumber; + + this._updateUIState(); + } + }, { + key: "setPagesCount", + value: function setPagesCount(pagesCount) { + this.pagesCount = pagesCount; + + this._updateUIState(); + } + }, { + key: "reset", + value: function reset() { + this.pageNumber = 0; + this.pagesCount = 0; + + this._updateUIState(); + + this.eventBus.dispatch('secondarytoolbarreset', { + source: this + }); + } + }, { + key: "_updateUIState", + value: function _updateUIState() { + this.items.firstPage.disabled = this.pageNumber <= 1; + this.items.lastPage.disabled = this.pageNumber >= this.pagesCount; + this.items.pageRotateCw.disabled = this.pagesCount === 0; + this.items.pageRotateCcw.disabled = this.pagesCount === 0; + } + }, { + key: "_bindClickListeners", + value: function _bindClickListeners() { + var _this2 = this; + + this.toggleButton.addEventListener('click', this.toggle.bind(this)); + + var _loop = function _loop(button) { + var _this2$buttons$button = _this2.buttons[button], + element = _this2$buttons$button.element, + eventName = _this2$buttons$button.eventName, + close = _this2$buttons$button.close, + eventDetails = _this2$buttons$button.eventDetails; + element.addEventListener('click', function (evt) { + if (eventName !== null) { + var details = { + source: _this2 + }; + + for (var property in eventDetails) { + details[property] = eventDetails[property]; + } + + _this2.eventBus.dispatch(eventName, details); + } + + if (close) { + _this2.close(); + } + }); + }; + + for (var button in this.buttons) { + _loop(button); + } + } + }, { + key: "_bindCursorToolsListener", + value: function _bindCursorToolsListener(buttons) { + this.eventBus.on('cursortoolchanged', function (_ref) { + var tool = _ref.tool; + buttons.cursorSelectToolButton.classList.toggle('toggled', tool === _pdf_cursor_tools.CursorTool.SELECT); + buttons.cursorHandToolButton.classList.toggle('toggled', tool === _pdf_cursor_tools.CursorTool.HAND); + }); + } + }, { + key: "_bindScrollModeListener", + value: function _bindScrollModeListener(buttons) { + var _this3 = this; + + function scrollModeChanged(_ref2) { + var mode = _ref2.mode; + buttons.scrollVerticalButton.classList.toggle('toggled', mode === _ui_utils.ScrollMode.VERTICAL); + buttons.scrollHorizontalButton.classList.toggle('toggled', mode === _ui_utils.ScrollMode.HORIZONTAL); + buttons.scrollWrappedButton.classList.toggle('toggled', mode === _ui_utils.ScrollMode.WRAPPED); + var isScrollModeHorizontal = mode === _ui_utils.ScrollMode.HORIZONTAL; + buttons.spreadNoneButton.disabled = isScrollModeHorizontal; + buttons.spreadOddButton.disabled = isScrollModeHorizontal; + buttons.spreadEvenButton.disabled = isScrollModeHorizontal; + } + + this.eventBus.on('scrollmodechanged', scrollModeChanged); + this.eventBus.on('secondarytoolbarreset', function (evt) { + if (evt.source === _this3) { + scrollModeChanged({ + mode: _ui_utils.ScrollMode.VERTICAL + }); + } + }); + } + }, { + key: "_bindSpreadModeListener", + value: function _bindSpreadModeListener(buttons) { + var _this4 = this; + + function spreadModeChanged(_ref3) { + var mode = _ref3.mode; + buttons.spreadNoneButton.classList.toggle('toggled', mode === _ui_utils.SpreadMode.NONE); + buttons.spreadOddButton.classList.toggle('toggled', mode === _ui_utils.SpreadMode.ODD); + buttons.spreadEvenButton.classList.toggle('toggled', mode === _ui_utils.SpreadMode.EVEN); + } + + this.eventBus.on('spreadmodechanged', spreadModeChanged); + this.eventBus.on('secondarytoolbarreset', function (evt) { + if (evt.source === _this4) { + spreadModeChanged({ + mode: _ui_utils.SpreadMode.NONE + }); + } + }); + } + }, { + key: "open", + value: function open() { + if (this.opened) { + return; + } + + this.opened = true; + + this._setMaxHeight(); + + this.toggleButton.classList.add('toggled'); + this.toolbar.classList.remove('hidden'); + } + }, { + key: "close", + value: function close() { + if (!this.opened) { + return; + } + + this.opened = false; + this.toolbar.classList.add('hidden'); + this.toggleButton.classList.remove('toggled'); + } + }, { + key: "toggle", + value: function toggle() { + if (this.opened) { + this.close(); + } else { + this.open(); + } + } + }, { + key: "_setMaxHeight", + value: function _setMaxHeight() { + if (!this.opened) { + return; + } + + this.containerHeight = this.mainContainer.clientHeight; + + if (this.containerHeight === this.previousContainerHeight) { + return; + } + + this.toolbarButtonContainer.setAttribute('style', 'max-height: ' + (this.containerHeight - _ui_utils.SCROLLBAR_PADDING) + 'px;'); + this.previousContainerHeight = this.containerHeight; + } + }, { + key: "isOpen", + get: function get() { + return this.opened; + } + }]); + + return SecondaryToolbar; +}(); + +exports.SecondaryToolbar = SecondaryToolbar; + +/***/ }), +/* 34 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFSinglePageViewer = void 0; + +var _base_viewer = __webpack_require__(29); + +var _pdfjsLib = __webpack_require__(7); + +function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } return _assertThisInitialized(self); } + +function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } + +function _get(target, property, receiver) { if (typeof Reflect !== "undefined" && Reflect.get) { _get = Reflect.get; } else { _get = function _get(target, property, receiver) { var base = _superPropBase(target, property); if (!base) return; var desc = Object.getOwnPropertyDescriptor(base, property); if (desc.get) { return desc.get.call(receiver); } return desc.value; }; } return _get(target, property, receiver || target); } + +function _superPropBase(object, property) { while (!Object.prototype.hasOwnProperty.call(object, property)) { object = _getPrototypeOf(object); if (object === null) break; } return object; } + +function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); } + +function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); } + +function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } + +var PDFSinglePageViewer = +/*#__PURE__*/ +function (_BaseViewer) { + _inherits(PDFSinglePageViewer, _BaseViewer); + + function PDFSinglePageViewer(options) { + var _this; + + _classCallCheck(this, PDFSinglePageViewer); + + _this = _possibleConstructorReturn(this, _getPrototypeOf(PDFSinglePageViewer).call(this, options)); + + _this.eventBus.on('pagesinit', function (evt) { + _this._ensurePageViewVisible(); + }); + + return _this; + } + + _createClass(PDFSinglePageViewer, [{ + key: "_resetView", + value: function _resetView() { + _get(_getPrototypeOf(PDFSinglePageViewer.prototype), "_resetView", this).call(this); + + this._previousPageNumber = 1; + this._shadowViewer = document.createDocumentFragment(); + this._updateScrollDown = null; + } + }, { + key: "_ensurePageViewVisible", + value: function _ensurePageViewVisible() { + var pageView = this._pages[this._currentPageNumber - 1]; + var previousPageView = this._pages[this._previousPageNumber - 1]; + var viewerNodes = this.viewer.childNodes; + + switch (viewerNodes.length) { + case 0: + this.viewer.appendChild(pageView.div); + break; + + case 1: + if (viewerNodes[0] !== previousPageView.div) { + throw new Error('_ensurePageViewVisible: Unexpected previously visible page.'); + } + + if (pageView === previousPageView) { + break; + } + + this._shadowViewer.appendChild(previousPageView.div); + + this.viewer.appendChild(pageView.div); + this.container.scrollTop = 0; + break; + + default: + throw new Error('_ensurePageViewVisible: Only one page should be visible at a time.'); + } + + this._previousPageNumber = this._currentPageNumber; + } + }, { + key: "_scrollUpdate", + value: function _scrollUpdate() { + if (this._updateScrollDown) { + this._updateScrollDown(); + } + + _get(_getPrototypeOf(PDFSinglePageViewer.prototype), "_scrollUpdate", this).call(this); + } + }, { + key: "_scrollIntoView", + value: function _scrollIntoView(_ref) { + var _this2 = this; + + var pageDiv = _ref.pageDiv, + _ref$pageSpot = _ref.pageSpot, + pageSpot = _ref$pageSpot === void 0 ? null : _ref$pageSpot, + _ref$pageNumber = _ref.pageNumber, + pageNumber = _ref$pageNumber === void 0 ? null : _ref$pageNumber; + + if (pageNumber) { + this._setCurrentPageNumber(pageNumber); + } + + var scrolledDown = this._currentPageNumber >= this._previousPageNumber; + + this._ensurePageViewVisible(); + + this.update(); + + _get(_getPrototypeOf(PDFSinglePageViewer.prototype), "_scrollIntoView", this).call(this, { + pageDiv: pageDiv, + pageSpot: pageSpot, + pageNumber: pageNumber + }); + + this._updateScrollDown = function () { + _this2.scroll.down = scrolledDown; + _this2._updateScrollDown = null; + }; + } + }, { + key: "_getVisiblePages", + value: function _getVisiblePages() { + return this._getCurrentVisiblePage(); + } + }, { + key: "_updateHelper", + value: function _updateHelper(visiblePages) {} + }, { + key: "_updateScrollMode", + value: function _updateScrollMode() {} + }, { + key: "_updateSpreadMode", + value: function _updateSpreadMode() {} + }, { + key: "_setDocumentViewerElement", + get: function get() { + return (0, _pdfjsLib.shadow)(this, '_setDocumentViewerElement', this._shadowViewer); + } + }, { + key: "_isScrollModeHorizontal", + get: function get() { + return (0, _pdfjsLib.shadow)(this, '_isScrollModeHorizontal', false); + } + }]); + + return PDFSinglePageViewer; +}(_base_viewer.BaseViewer); + +exports.PDFSinglePageViewer = PDFSinglePageViewer; + +/***/ }), +/* 35 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.Toolbar = void 0; + +var _ui_utils = __webpack_require__(6); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var PAGE_NUMBER_LOADING_INDICATOR = 'visiblePageIsLoading'; +var SCALE_SELECT_CONTAINER_PADDING = 8; +var SCALE_SELECT_PADDING = 22; + +var Toolbar = +/*#__PURE__*/ +function () { + function Toolbar(options, eventBus) { + var l10n = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : _ui_utils.NullL10n; + + _classCallCheck(this, Toolbar); + + this.toolbar = options.container; + this.eventBus = eventBus; + this.l10n = l10n; + this.items = options; + this._wasLocalized = false; + this.reset(); + + this._bindListeners(); + } + + _createClass(Toolbar, [{ + key: "setPageNumber", + value: function setPageNumber(pageNumber, pageLabel) { + this.pageNumber = pageNumber; + this.pageLabel = pageLabel; + + this._updateUIState(false); + } + }, { + key: "setPagesCount", + value: function setPagesCount(pagesCount, hasPageLabels) { + this.pagesCount = pagesCount; + this.hasPageLabels = hasPageLabels; + + this._updateUIState(true); + } + }, { + key: "setPageScale", + value: function setPageScale(pageScaleValue, pageScale) { + this.pageScaleValue = (pageScaleValue || pageScale).toString(); + this.pageScale = pageScale; + + this._updateUIState(false); + } + }, { + key: "reset", + value: function reset() { + this.pageNumber = 0; + this.pageLabel = null; + this.hasPageLabels = false; + this.pagesCount = 0; + this.pageScaleValue = _ui_utils.DEFAULT_SCALE_VALUE; + this.pageScale = _ui_utils.DEFAULT_SCALE; + + this._updateUIState(true); + } + }, { + key: "_bindListeners", + value: function _bindListeners() { + var _this = this; + + var eventBus = this.eventBus, + items = this.items; + var self = this; + items.previous.addEventListener('click', function () { + eventBus.dispatch('previouspage', { + source: self + }); + }); + items.next.addEventListener('click', function () { + eventBus.dispatch('nextpage', { + source: self + }); + }); + items.zoomIn.addEventListener('click', function () { + eventBus.dispatch('zoomin', { + source: self + }); + }); + items.zoomOut.addEventListener('click', function () { + eventBus.dispatch('zoomout', { + source: self + }); + }); + items.pageNumber.addEventListener('click', function () { + this.select(); + }); + items.pageNumber.addEventListener('change', function () { + eventBus.dispatch('pagenumberchanged', { + source: self, + value: this.value + }); + }); + items.scaleSelect.addEventListener('change', function () { + if (this.value === 'custom') { + return; + } + + eventBus.dispatch('scalechanged', { + source: self, + value: this.value + }); + }); + items.presentationModeButton.addEventListener('click', function () { + eventBus.dispatch('presentationmode', { + source: self + }); + }); + items.openFile.addEventListener('click', function () { + eventBus.dispatch('openfile', { + source: self + }); + }); + items.print.addEventListener('click', function () { + eventBus.dispatch('print', { + source: self + }); + }); + items.download.addEventListener('click', function () { + eventBus.dispatch('download', { + source: self + }); + }); + items.scaleSelect.oncontextmenu = _ui_utils.noContextMenuHandler; + eventBus.on('localized', function () { + _this._localized(); + }); + } + }, { + key: "_localized", + value: function _localized() { + this._wasLocalized = true; + + this._adjustScaleWidth(); + + this._updateUIState(true); + } + }, { + key: "_updateUIState", + value: function _updateUIState() { + var resetNumPages = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + + if (!this._wasLocalized) { + return; + } + + var pageNumber = this.pageNumber, + pagesCount = this.pagesCount, + pageScaleValue = this.pageScaleValue, + pageScale = this.pageScale, + items = this.items; + + if (resetNumPages) { + if (this.hasPageLabels) { + items.pageNumber.type = 'text'; + } else { + items.pageNumber.type = 'number'; + this.l10n.get('of_pages', { + pagesCount: pagesCount + }, 'of {{pagesCount}}').then(function (msg) { + items.numPages.textContent = msg; + }); + } + + items.pageNumber.max = pagesCount; + } + + if (this.hasPageLabels) { + items.pageNumber.value = this.pageLabel; + this.l10n.get('page_of_pages', { + pageNumber: pageNumber, + pagesCount: pagesCount + }, '({{pageNumber}} of {{pagesCount}})').then(function (msg) { + items.numPages.textContent = msg; + }); + } else { + items.pageNumber.value = pageNumber; + } + + items.previous.disabled = pageNumber <= 1; + items.next.disabled = pageNumber >= pagesCount; + items.zoomOut.disabled = pageScale <= _ui_utils.MIN_SCALE; + items.zoomIn.disabled = pageScale >= _ui_utils.MAX_SCALE; + var customScale = Math.round(pageScale * 10000) / 100; + this.l10n.get('page_scale_percent', { + scale: customScale + }, '{{scale}}%').then(function (msg) { + var options = items.scaleSelect.options; + var predefinedValueFound = false; + + for (var i = 0, ii = options.length; i < ii; i++) { + var option = options[i]; + + if (option.value !== pageScaleValue) { + option.selected = false; + continue; + } + + option.selected = true; + predefinedValueFound = true; + } + + if (!predefinedValueFound) { + items.customScaleOption.textContent = msg; + items.customScaleOption.selected = true; + } + }); + } + }, { + key: "updateLoadingIndicatorState", + value: function updateLoadingIndicatorState() { + var loading = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + var pageNumberInput = this.items.pageNumber; + pageNumberInput.classList.toggle(PAGE_NUMBER_LOADING_INDICATOR, loading); + } + }, { + key: "_adjustScaleWidth", + value: function _adjustScaleWidth() { + var container = this.items.scaleSelectContainer; + var select = this.items.scaleSelect; + + _ui_utils.animationStarted.then(function () { + if (container.clientWidth === 0) { + container.setAttribute('style', 'display: inherit;'); + } + + if (container.clientWidth > 0) { + select.setAttribute('style', 'min-width: inherit;'); + var width = select.clientWidth + SCALE_SELECT_CONTAINER_PADDING; + select.setAttribute('style', 'min-width: ' + (width + SCALE_SELECT_PADDING) + 'px;'); + container.setAttribute('style', 'min-width: ' + width + 'px; ' + 'max-width: ' + width + 'px;'); + } + }); + } + }]); + + return Toolbar; +}(); + +exports.Toolbar = Toolbar; + +/***/ }), +/* 36 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ViewHistory = void 0; + +var _regenerator = _interopRequireDefault(__webpack_require__(2)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } + +function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var DEFAULT_VIEW_HISTORY_CACHE_SIZE = 20; + +var ViewHistory = +/*#__PURE__*/ +function () { + function ViewHistory(fingerprint) { + var _this = this; + + var cacheSize = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : DEFAULT_VIEW_HISTORY_CACHE_SIZE; + + _classCallCheck(this, ViewHistory); + + this.fingerprint = fingerprint; + this.cacheSize = cacheSize; + this._initializedPromise = this._readFromStorage().then(function (databaseStr) { + var database = JSON.parse(databaseStr || '{}'); + + if (!('files' in database)) { + database.files = []; + } else { + while (database.files.length >= _this.cacheSize) { + database.files.shift(); + } + } + + var index = -1; + + for (var i = 0, length = database.files.length; i < length; i++) { + var branch = database.files[i]; + + if (branch.fingerprint === _this.fingerprint) { + index = i; + break; + } + } + + if (index === -1) { + index = database.files.push({ + fingerprint: _this.fingerprint + }) - 1; + } + + _this.file = database.files[index]; + _this.database = database; + }); + } + + _createClass(ViewHistory, [{ + key: "_writeToStorage", + value: function () { + var _writeToStorage2 = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee() { + var databaseStr; + return _regenerator.default.wrap(function _callee$(_context) { + while (1) { + switch (_context.prev = _context.next) { + case 0: + databaseStr = JSON.stringify(this.database); + localStorage.setItem('pdfjs.history', databaseStr); + + case 2: + case "end": + return _context.stop(); + } + } + }, _callee, this); + })); + + function _writeToStorage() { + return _writeToStorage2.apply(this, arguments); + } + + return _writeToStorage; + }() + }, { + key: "_readFromStorage", + value: function () { + var _readFromStorage2 = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee2() { + return _regenerator.default.wrap(function _callee2$(_context2) { + while (1) { + switch (_context2.prev = _context2.next) { + case 0: + return _context2.abrupt("return", localStorage.getItem('pdfjs.history')); + + case 1: + case "end": + return _context2.stop(); + } + } + }, _callee2, this); + })); + + function _readFromStorage() { + return _readFromStorage2.apply(this, arguments); + } + + return _readFromStorage; + }() + }, { + key: "set", + value: function () { + var _set = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee3(name, val) { + return _regenerator.default.wrap(function _callee3$(_context3) { + while (1) { + switch (_context3.prev = _context3.next) { + case 0: + _context3.next = 2; + return this._initializedPromise; + + case 2: + this.file[name] = val; + return _context3.abrupt("return", this._writeToStorage()); + + case 4: + case "end": + return _context3.stop(); + } + } + }, _callee3, this); + })); + + function set(_x, _x2) { + return _set.apply(this, arguments); + } + + return set; + }() + }, { + key: "setMultiple", + value: function () { + var _setMultiple = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee4(properties) { + var name; + return _regenerator.default.wrap(function _callee4$(_context4) { + while (1) { + switch (_context4.prev = _context4.next) { + case 0: + _context4.next = 2; + return this._initializedPromise; + + case 2: + for (name in properties) { + this.file[name] = properties[name]; + } + + return _context4.abrupt("return", this._writeToStorage()); + + case 4: + case "end": + return _context4.stop(); + } + } + }, _callee4, this); + })); + + function setMultiple(_x3) { + return _setMultiple.apply(this, arguments); + } + + return setMultiple; + }() + }, { + key: "get", + value: function () { + var _get = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee5(name, defaultValue) { + var val; + return _regenerator.default.wrap(function _callee5$(_context5) { + while (1) { + switch (_context5.prev = _context5.next) { + case 0: + _context5.next = 2; + return this._initializedPromise; + + case 2: + val = this.file[name]; + return _context5.abrupt("return", val !== undefined ? val : defaultValue); + + case 4: + case "end": + return _context5.stop(); + } + } + }, _callee5, this); + })); + + function get(_x4, _x5) { + return _get.apply(this, arguments); + } + + return get; + }() + }, { + key: "getMultiple", + value: function () { + var _getMultiple = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee6(properties) { + var values, name, val; + return _regenerator.default.wrap(function _callee6$(_context6) { + while (1) { + switch (_context6.prev = _context6.next) { + case 0: + _context6.next = 2; + return this._initializedPromise; + + case 2: + values = Object.create(null); + + for (name in properties) { + val = this.file[name]; + values[name] = val !== undefined ? val : properties[name]; + } + + return _context6.abrupt("return", values); + + case 5: + case "end": + return _context6.stop(); + } + } + }, _callee6, this); + })); + + function getMultiple(_x6) { + return _getMultiple.apply(this, arguments); + } + + return getMultiple; + }() + }]); + + return ViewHistory; +}(); + +exports.ViewHistory = ViewHistory; + +/***/ }), +/* 37 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.GenericCom = void 0; + +var _regenerator = _interopRequireDefault(__webpack_require__(2)); + +var _app = __webpack_require__(1); + +var _preferences = __webpack_require__(38); + +var _download_manager = __webpack_require__(39); + +var _genericl10n = __webpack_require__(40); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } + +function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } return _assertThisInitialized(self); } + +function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } + +function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); } + +function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); } + +function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } + +; +var GenericCom = {}; +exports.GenericCom = GenericCom; + +var GenericPreferences = +/*#__PURE__*/ +function (_BasePreferences) { + _inherits(GenericPreferences, _BasePreferences); + + function GenericPreferences() { + _classCallCheck(this, GenericPreferences); + + return _possibleConstructorReturn(this, _getPrototypeOf(GenericPreferences).apply(this, arguments)); + } + + _createClass(GenericPreferences, [{ + key: "_writeToStorage", + value: function () { + var _writeToStorage2 = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee(prefObj) { + return _regenerator.default.wrap(function _callee$(_context) { + while (1) { + switch (_context.prev = _context.next) { + case 0: + localStorage.setItem('pdfjs.preferences', JSON.stringify(prefObj)); + + case 1: + case "end": + return _context.stop(); + } + } + }, _callee, this); + })); + + function _writeToStorage(_x) { + return _writeToStorage2.apply(this, arguments); + } + + return _writeToStorage; + }() + }, { + key: "_readFromStorage", + value: function () { + var _readFromStorage2 = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee2(prefObj) { + return _regenerator.default.wrap(function _callee2$(_context2) { + while (1) { + switch (_context2.prev = _context2.next) { + case 0: + return _context2.abrupt("return", JSON.parse(localStorage.getItem('pdfjs.preferences'))); + + case 1: + case "end": + return _context2.stop(); + } + } + }, _callee2, this); + })); + + function _readFromStorage(_x2) { + return _readFromStorage2.apply(this, arguments); + } + + return _readFromStorage; + }() + }]); + + return GenericPreferences; +}(_preferences.BasePreferences); + +var GenericExternalServices = Object.create(_app.DefaultExternalServices); + +GenericExternalServices.createDownloadManager = function (options) { + return new _download_manager.DownloadManager(options); +}; + +GenericExternalServices.createPreferences = function () { + return new GenericPreferences(); +}; + +GenericExternalServices.createL10n = function (_ref) { + var _ref$locale = _ref.locale, + locale = _ref$locale === void 0 ? 'en-US' : _ref$locale; + return new _genericl10n.GenericL10n(locale); +}; + +_app.PDFViewerApplication.externalServices = GenericExternalServices; + +/***/ }), +/* 38 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.BasePreferences = void 0; + +var _regenerator = _interopRequireDefault(__webpack_require__(2)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } + +function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; } + +function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var defaultPreferences = null; + +function getDefaultPreferences() { + if (!defaultPreferences) { + defaultPreferences = Promise.resolve({ + "viewOnLoad": 0, + "defaultZoomValue": "", + "sidebarViewOnLoad": -1, + "cursorToolOnLoad": 0, + "enableWebGL": false, + "eventBusDispatchToDOM": false, + "pdfBugEnabled": false, + "disableRange": false, + "disableStream": false, + "disableAutoFetch": false, + "disableFontFace": false, + "textLayerMode": 1, + "useOnlyCssZoom": false, + "externalLinkTarget": 0, + "renderer": "canvas", + "renderInteractiveForms": false, + "enablePrintAutoRotate": false, + "disablePageLabels": false, + "historyUpdateUrl": false, + "scrollModeOnLoad": -1, + "spreadModeOnLoad": -1 + }); + } + + return defaultPreferences; +} + +var BasePreferences = +/*#__PURE__*/ +function () { + function BasePreferences() { + var _this = this; + + _classCallCheck(this, BasePreferences); + + if (this.constructor === BasePreferences) { + throw new Error('Cannot initialize BasePreferences.'); + } + + this.prefs = null; + this._initializedPromise = getDefaultPreferences().then(function (defaults) { + Object.defineProperty(_this, 'defaults', { + value: Object.freeze(defaults), + writable: false, + enumerable: true, + configurable: false + }); + _this.prefs = Object.assign(Object.create(null), defaults); + return _this._readFromStorage(defaults); + }).then(function (prefs) { + if (!prefs) { + return; + } + + for (var name in prefs) { + var defaultValue = _this.defaults[name], + prefValue = prefs[name]; + + if (defaultValue === undefined || _typeof(prefValue) !== _typeof(defaultValue)) { + continue; + } + + _this.prefs[name] = prefValue; + } + }); + } + + _createClass(BasePreferences, [{ + key: "_writeToStorage", + value: function () { + var _writeToStorage2 = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee(prefObj) { + return _regenerator.default.wrap(function _callee$(_context) { + while (1) { + switch (_context.prev = _context.next) { + case 0: + throw new Error('Not implemented: _writeToStorage'); + + case 1: + case "end": + return _context.stop(); + } + } + }, _callee, this); + })); + + function _writeToStorage(_x) { + return _writeToStorage2.apply(this, arguments); + } + + return _writeToStorage; + }() + }, { + key: "_readFromStorage", + value: function () { + var _readFromStorage2 = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee2(prefObj) { + return _regenerator.default.wrap(function _callee2$(_context2) { + while (1) { + switch (_context2.prev = _context2.next) { + case 0: + throw new Error('Not implemented: _readFromStorage'); + + case 1: + case "end": + return _context2.stop(); + } + } + }, _callee2, this); + })); + + function _readFromStorage(_x2) { + return _readFromStorage2.apply(this, arguments); + } + + return _readFromStorage; + }() + }, { + key: "reset", + value: function () { + var _reset = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee3() { + return _regenerator.default.wrap(function _callee3$(_context3) { + while (1) { + switch (_context3.prev = _context3.next) { + case 0: + _context3.next = 2; + return this._initializedPromise; + + case 2: + this.prefs = Object.assign(Object.create(null), this.defaults); + return _context3.abrupt("return", this._writeToStorage(this.defaults)); + + case 4: + case "end": + return _context3.stop(); + } + } + }, _callee3, this); + })); + + function reset() { + return _reset.apply(this, arguments); + } + + return reset; + }() + }, { + key: "set", + value: function () { + var _set = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee4(name, value) { + var defaultValue, valueType, defaultType; + return _regenerator.default.wrap(function _callee4$(_context4) { + while (1) { + switch (_context4.prev = _context4.next) { + case 0: + _context4.next = 2; + return this._initializedPromise; + + case 2: + defaultValue = this.defaults[name]; + + if (!(defaultValue === undefined)) { + _context4.next = 7; + break; + } + + throw new Error("Set preference: \"".concat(name, "\" is undefined.")); + + case 7: + if (!(value === undefined)) { + _context4.next = 9; + break; + } + + throw new Error('Set preference: no value is specified.'); + + case 9: + valueType = _typeof(value); + defaultType = _typeof(defaultValue); + + if (!(valueType !== defaultType)) { + _context4.next = 19; + break; + } + + if (!(valueType === 'number' && defaultType === 'string')) { + _context4.next = 16; + break; + } + + value = value.toString(); + _context4.next = 17; + break; + + case 16: + throw new Error("Set preference: \"".concat(value, "\" is a ").concat(valueType, ", ") + "expected a ".concat(defaultType, ".")); + + case 17: + _context4.next = 21; + break; + + case 19: + if (!(valueType === 'number' && !Number.isInteger(value))) { + _context4.next = 21; + break; + } + + throw new Error("Set preference: \"".concat(value, "\" must be an integer.")); + + case 21: + this.prefs[name] = value; + return _context4.abrupt("return", this._writeToStorage(this.prefs)); + + case 23: + case "end": + return _context4.stop(); + } + } + }, _callee4, this); + })); + + function set(_x3, _x4) { + return _set.apply(this, arguments); + } + + return set; + }() + }, { + key: "get", + value: function () { + var _get = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee5(name) { + var defaultValue, prefValue; + return _regenerator.default.wrap(function _callee5$(_context5) { + while (1) { + switch (_context5.prev = _context5.next) { + case 0: + _context5.next = 2; + return this._initializedPromise; + + case 2: + defaultValue = this.defaults[name]; + + if (!(defaultValue === undefined)) { + _context5.next = 7; + break; + } + + throw new Error("Get preference: \"".concat(name, "\" is undefined.")); + + case 7: + prefValue = this.prefs[name]; + + if (!(prefValue !== undefined)) { + _context5.next = 10; + break; + } + + return _context5.abrupt("return", prefValue); + + case 10: + return _context5.abrupt("return", defaultValue); + + case 11: + case "end": + return _context5.stop(); + } + } + }, _callee5, this); + })); + + function get(_x5) { + return _get.apply(this, arguments); + } + + return get; + }() + }, { + key: "getAll", + value: function () { + var _getAll = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee6() { + return _regenerator.default.wrap(function _callee6$(_context6) { + while (1) { + switch (_context6.prev = _context6.next) { + case 0: + _context6.next = 2; + return this._initializedPromise; + + case 2: + return _context6.abrupt("return", Object.assign(Object.create(null), this.defaults, this.prefs)); + + case 3: + case "end": + return _context6.stop(); + } + } + }, _callee6, this); + })); + + function getAll() { + return _getAll.apply(this, arguments); + } + + return getAll; + }() + }]); + + return BasePreferences; +}(); + +exports.BasePreferences = BasePreferences; + +/***/ }), +/* 39 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.DownloadManager = void 0; + +var _pdfjsLib = __webpack_require__(7); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +; +var DISABLE_CREATE_OBJECT_URL = _pdfjsLib.apiCompatibilityParams.disableCreateObjectURL || false; + +function _download(blobUrl, filename) { + var a = document.createElement('a'); + + if (!a.click) { + throw new Error('DownloadManager: "a.click()" is not supported.'); + } + + a.href = blobUrl; + a.target = '_parent'; + + if ('download' in a) { + a.download = filename; + } + + (document.body || document.documentElement).appendChild(a); + a.click(); + a.remove(); +} + +var DownloadManager = +/*#__PURE__*/ +function () { + function DownloadManager(_ref) { + var _ref$disableCreateObj = _ref.disableCreateObjectURL, + disableCreateObjectURL = _ref$disableCreateObj === void 0 ? DISABLE_CREATE_OBJECT_URL : _ref$disableCreateObj; + + _classCallCheck(this, DownloadManager); + + this.disableCreateObjectURL = disableCreateObjectURL; + } + + _createClass(DownloadManager, [{ + key: "downloadUrl", + value: function downloadUrl(url, filename) { + if (!(0, _pdfjsLib.createValidAbsoluteUrl)(url, 'http://example.com')) { + return; + } + + _download(url + '#pdfjs.action=download', filename); + } + }, { + key: "downloadData", + value: function downloadData(data, filename, contentType) { + if (navigator.msSaveBlob) { + return navigator.msSaveBlob(new Blob([data], { + type: contentType + }), filename); + } + + var blobUrl = (0, _pdfjsLib.createObjectURL)(data, contentType, this.disableCreateObjectURL); + + _download(blobUrl, filename); + } + }, { + key: "download", + value: function download(blob, url, filename) { + if (navigator.msSaveBlob) { + if (!navigator.msSaveBlob(blob, filename)) { + this.downloadUrl(url, filename); + } + + return; + } + + if (this.disableCreateObjectURL) { + this.downloadUrl(url, filename); + return; + } + + var blobUrl = _pdfjsLib.URL.createObjectURL(blob); + + _download(blobUrl, filename); + } + }]); + + return DownloadManager; +}(); + +exports.DownloadManager = DownloadManager; + +/***/ }), +/* 40 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.GenericL10n = void 0; + +var _regenerator = _interopRequireDefault(__webpack_require__(2)); + +__webpack_require__(41); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } + +function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var webL10n = document.webL10n; + +var GenericL10n = +/*#__PURE__*/ +function () { + function GenericL10n(lang) { + _classCallCheck(this, GenericL10n); + + this._lang = lang; + this._ready = new Promise(function (resolve, reject) { + webL10n.setLanguage(lang, function () { + resolve(webL10n); + }); + }); + } + + _createClass(GenericL10n, [{ + key: "getLanguage", + value: function () { + var _getLanguage = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee() { + var l10n; + return _regenerator.default.wrap(function _callee$(_context) { + while (1) { + switch (_context.prev = _context.next) { + case 0: + _context.next = 2; + return this._ready; + + case 2: + l10n = _context.sent; + return _context.abrupt("return", l10n.getLanguage()); + + case 4: + case "end": + return _context.stop(); + } + } + }, _callee, this); + })); + + function getLanguage() { + return _getLanguage.apply(this, arguments); + } + + return getLanguage; + }() + }, { + key: "getDirection", + value: function () { + var _getDirection = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee2() { + var l10n; + return _regenerator.default.wrap(function _callee2$(_context2) { + while (1) { + switch (_context2.prev = _context2.next) { + case 0: + _context2.next = 2; + return this._ready; + + case 2: + l10n = _context2.sent; + return _context2.abrupt("return", l10n.getDirection()); + + case 4: + case "end": + return _context2.stop(); + } + } + }, _callee2, this); + })); + + function getDirection() { + return _getDirection.apply(this, arguments); + } + + return getDirection; + }() + }, { + key: "get", + value: function () { + var _get = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee3(property, args, fallback) { + var l10n; + return _regenerator.default.wrap(function _callee3$(_context3) { + while (1) { + switch (_context3.prev = _context3.next) { + case 0: + _context3.next = 2; + return this._ready; + + case 2: + l10n = _context3.sent; + return _context3.abrupt("return", l10n.get(property, args, fallback)); + + case 4: + case "end": + return _context3.stop(); + } + } + }, _callee3, this); + })); + + function get(_x, _x2, _x3) { + return _get.apply(this, arguments); + } + + return get; + }() + }, { + key: "translate", + value: function () { + var _translate = _asyncToGenerator( + /*#__PURE__*/ + _regenerator.default.mark(function _callee4(element) { + var l10n; + return _regenerator.default.wrap(function _callee4$(_context4) { + while (1) { + switch (_context4.prev = _context4.next) { + case 0: + _context4.next = 2; + return this._ready; + + case 2: + l10n = _context4.sent; + return _context4.abrupt("return", l10n.translate(element)); + + case 4: + case "end": + return _context4.stop(); + } + } + }, _callee4, this); + })); + + function translate(_x4) { + return _translate.apply(this, arguments); + } + + return translate; + }() + }]); + + return GenericL10n; +}(); + +exports.GenericL10n = GenericL10n; + +/***/ }), +/* 41 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +document.webL10n = function (window, document, undefined) { + var gL10nData = {}; + var gTextData = ''; + var gTextProp = 'textContent'; + var gLanguage = ''; + var gMacros = {}; + var gReadyState = 'loading'; + var gAsyncResourceLoading = true; + + function getL10nResourceLinks() { + return document.querySelectorAll('link[type="application/l10n"]'); + } + + function getL10nDictionary() { + var script = document.querySelector('script[type="application/l10n"]'); + return script ? JSON.parse(script.innerHTML) : null; + } + + function getTranslatableChildren(element) { + return element ? element.querySelectorAll('*[data-l10n-id]') : []; + } + + function getL10nAttributes(element) { + if (!element) return {}; + var l10nId = element.getAttribute('data-l10n-id'); + var l10nArgs = element.getAttribute('data-l10n-args'); + var args = {}; + + if (l10nArgs) { + try { + args = JSON.parse(l10nArgs); + } catch (e) { + console.warn('could not parse arguments for #' + l10nId); + } + } + + return { + id: l10nId, + args: args + }; + } + + function fireL10nReadyEvent(lang) { + var evtObject = document.createEvent('Event'); + evtObject.initEvent('localized', true, false); + evtObject.language = lang; + document.dispatchEvent(evtObject); + } + + function xhrLoadText(url, onSuccess, onFailure) { + onSuccess = onSuccess || function _onSuccess(data) {}; + + onFailure = onFailure || function _onFailure() {}; + + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, gAsyncResourceLoading); + + if (xhr.overrideMimeType) { + xhr.overrideMimeType('text/plain; charset=utf-8'); + } + + xhr.onreadystatechange = function () { + if (xhr.readyState == 4) { + if (xhr.status == 200 || xhr.status === 0) { + onSuccess(xhr.responseText); + } else { + onFailure(); + } + } + }; + + xhr.onerror = onFailure; + xhr.ontimeout = onFailure; + + try { + xhr.send(null); + } catch (e) { + onFailure(); + } + } + + function parseResource(href, lang, successCallback, failureCallback) { + var baseURL = href.replace(/[^\/]*$/, '') || './'; + + function evalString(text) { + if (text.lastIndexOf('\\') < 0) return text; + return text.replace(/\\\\/g, '\\').replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\t/g, '\t').replace(/\\b/g, '\b').replace(/\\f/g, '\f').replace(/\\{/g, '{').replace(/\\}/g, '}').replace(/\\"/g, '"').replace(/\\'/g, "'"); + } + + function parseProperties(text, parsedPropertiesCallback) { + var dictionary = {}; + var reBlank = /^\s*|\s*$/; + var reComment = /^\s*#|^\s*$/; + var reSection = /^\s*\[(.*)\]\s*$/; + var reImport = /^\s*@import\s+url\((.*)\)\s*$/i; + var reSplit = /^([^=\s]*)\s*=\s*(.+)$/; + + function parseRawLines(rawText, extendedSyntax, parsedRawLinesCallback) { + var entries = rawText.replace(reBlank, '').split(/[\r\n]+/); + var currentLang = '*'; + var genericLang = lang.split('-', 1)[0]; + var skipLang = false; + var match = ''; + + function nextEntry() { + while (true) { + if (!entries.length) { + parsedRawLinesCallback(); + return; + } + + var line = entries.shift(); + if (reComment.test(line)) continue; + + if (extendedSyntax) { + match = reSection.exec(line); + + if (match) { + currentLang = match[1].toLowerCase(); + skipLang = currentLang !== '*' && currentLang !== lang && currentLang !== genericLang; + continue; + } else if (skipLang) { + continue; + } + + match = reImport.exec(line); + + if (match) { + loadImport(baseURL + match[1], nextEntry); + return; + } + } + + var tmp = line.match(reSplit); + + if (tmp && tmp.length == 3) { + dictionary[tmp[1]] = evalString(tmp[2]); + } + } + } + + nextEntry(); + } + + function loadImport(url, callback) { + xhrLoadText(url, function (content) { + parseRawLines(content, false, callback); + }, function () { + console.warn(url + ' not found.'); + callback(); + }); + } + + parseRawLines(text, true, function () { + parsedPropertiesCallback(dictionary); + }); + } + + xhrLoadText(href, function (response) { + gTextData += response; + parseProperties(response, function (data) { + for (var key in data) { + var id, + prop, + index = key.lastIndexOf('.'); + + if (index > 0) { + id = key.substring(0, index); + prop = key.substring(index + 1); + } else { + id = key; + prop = gTextProp; + } + + if (!gL10nData[id]) { + gL10nData[id] = {}; + } + + gL10nData[id][prop] = data[key]; + } + + if (successCallback) { + successCallback(); + } + }); + }, failureCallback); + } + + function loadLocale(lang, callback) { + if (lang) { + lang = lang.toLowerCase(); + } + + callback = callback || function _callback() {}; + + clear(); + gLanguage = lang; + var langLinks = getL10nResourceLinks(); + var langCount = langLinks.length; + + if (langCount === 0) { + var dict = getL10nDictionary(); + + if (dict && dict.locales && dict.default_locale) { + console.log('using the embedded JSON directory, early way out'); + gL10nData = dict.locales[lang]; + + if (!gL10nData) { + var defaultLocale = dict.default_locale.toLowerCase(); + + for (var anyCaseLang in dict.locales) { + anyCaseLang = anyCaseLang.toLowerCase(); + + if (anyCaseLang === lang) { + gL10nData = dict.locales[lang]; + break; + } else if (anyCaseLang === defaultLocale) { + gL10nData = dict.locales[defaultLocale]; + } + } + } + + callback(); + } else { + console.log('no resource to load, early way out'); + } + + fireL10nReadyEvent(lang); + gReadyState = 'complete'; + return; + } + + var onResourceLoaded = null; + var gResourceCount = 0; + + onResourceLoaded = function onResourceLoaded() { + gResourceCount++; + + if (gResourceCount >= langCount) { + callback(); + fireL10nReadyEvent(lang); + gReadyState = 'complete'; + } + }; + + function L10nResourceLink(link) { + var href = link.href; + + this.load = function (lang, callback) { + parseResource(href, lang, callback, function () { + console.warn(href + ' not found.'); + console.warn('"' + lang + '" resource not found'); + gLanguage = ''; + callback(); + }); + }; + } + + for (var i = 0; i < langCount; i++) { + var resource = new L10nResourceLink(langLinks[i]); + resource.load(lang, onResourceLoaded); + } + } + + function clear() { + gL10nData = {}; + gTextData = ''; + gLanguage = ''; + } + + function getPluralRules(lang) { + var locales2rules = { + 'af': 3, + 'ak': 4, + 'am': 4, + 'ar': 1, + 'asa': 3, + 'az': 0, + 'be': 11, + 'bem': 3, + 'bez': 3, + 'bg': 3, + 'bh': 4, + 'bm': 0, + 'bn': 3, + 'bo': 0, + 'br': 20, + 'brx': 3, + 'bs': 11, + 'ca': 3, + 'cgg': 3, + 'chr': 3, + 'cs': 12, + 'cy': 17, + 'da': 3, + 'de': 3, + 'dv': 3, + 'dz': 0, + 'ee': 3, + 'el': 3, + 'en': 3, + 'eo': 3, + 'es': 3, + 'et': 3, + 'eu': 3, + 'fa': 0, + 'ff': 5, + 'fi': 3, + 'fil': 4, + 'fo': 3, + 'fr': 5, + 'fur': 3, + 'fy': 3, + 'ga': 8, + 'gd': 24, + 'gl': 3, + 'gsw': 3, + 'gu': 3, + 'guw': 4, + 'gv': 23, + 'ha': 3, + 'haw': 3, + 'he': 2, + 'hi': 4, + 'hr': 11, + 'hu': 0, + 'id': 0, + 'ig': 0, + 'ii': 0, + 'is': 3, + 'it': 3, + 'iu': 7, + 'ja': 0, + 'jmc': 3, + 'jv': 0, + 'ka': 0, + 'kab': 5, + 'kaj': 3, + 'kcg': 3, + 'kde': 0, + 'kea': 0, + 'kk': 3, + 'kl': 3, + 'km': 0, + 'kn': 0, + 'ko': 0, + 'ksb': 3, + 'ksh': 21, + 'ku': 3, + 'kw': 7, + 'lag': 18, + 'lb': 3, + 'lg': 3, + 'ln': 4, + 'lo': 0, + 'lt': 10, + 'lv': 6, + 'mas': 3, + 'mg': 4, + 'mk': 16, + 'ml': 3, + 'mn': 3, + 'mo': 9, + 'mr': 3, + 'ms': 0, + 'mt': 15, + 'my': 0, + 'nah': 3, + 'naq': 7, + 'nb': 3, + 'nd': 3, + 'ne': 3, + 'nl': 3, + 'nn': 3, + 'no': 3, + 'nr': 3, + 'nso': 4, + 'ny': 3, + 'nyn': 3, + 'om': 3, + 'or': 3, + 'pa': 3, + 'pap': 3, + 'pl': 13, + 'ps': 3, + 'pt': 3, + 'rm': 3, + 'ro': 9, + 'rof': 3, + 'ru': 11, + 'rwk': 3, + 'sah': 0, + 'saq': 3, + 'se': 7, + 'seh': 3, + 'ses': 0, + 'sg': 0, + 'sh': 11, + 'shi': 19, + 'sk': 12, + 'sl': 14, + 'sma': 7, + 'smi': 7, + 'smj': 7, + 'smn': 7, + 'sms': 7, + 'sn': 3, + 'so': 3, + 'sq': 3, + 'sr': 11, + 'ss': 3, + 'ssy': 3, + 'st': 3, + 'sv': 3, + 'sw': 3, + 'syr': 3, + 'ta': 3, + 'te': 3, + 'teo': 3, + 'th': 0, + 'ti': 4, + 'tig': 3, + 'tk': 3, + 'tl': 4, + 'tn': 3, + 'to': 0, + 'tr': 0, + 'ts': 3, + 'tzm': 22, + 'uk': 11, + 'ur': 3, + 've': 3, + 'vi': 0, + 'vun': 3, + 'wa': 4, + 'wae': 3, + 'wo': 0, + 'xh': 3, + 'xog': 3, + 'yo': 0, + 'zh': 0, + 'zu': 3 + }; + + function isIn(n, list) { + return list.indexOf(n) !== -1; + } + + function isBetween(n, start, end) { + return start <= n && n <= end; + } + + var pluralRules = { + '0': function _(n) { + return 'other'; + }, + '1': function _(n) { + if (isBetween(n % 100, 3, 10)) return 'few'; + if (n === 0) return 'zero'; + if (isBetween(n % 100, 11, 99)) return 'many'; + if (n == 2) return 'two'; + if (n == 1) return 'one'; + return 'other'; + }, + '2': function _(n) { + if (n !== 0 && n % 10 === 0) return 'many'; + if (n == 2) return 'two'; + if (n == 1) return 'one'; + return 'other'; + }, + '3': function _(n) { + if (n == 1) return 'one'; + return 'other'; + }, + '4': function _(n) { + if (isBetween(n, 0, 1)) return 'one'; + return 'other'; + }, + '5': function _(n) { + if (isBetween(n, 0, 2) && n != 2) return 'one'; + return 'other'; + }, + '6': function _(n) { + if (n === 0) return 'zero'; + if (n % 10 == 1 && n % 100 != 11) return 'one'; + return 'other'; + }, + '7': function _(n) { + if (n == 2) return 'two'; + if (n == 1) return 'one'; + return 'other'; + }, + '8': function _(n) { + if (isBetween(n, 3, 6)) return 'few'; + if (isBetween(n, 7, 10)) return 'many'; + if (n == 2) return 'two'; + if (n == 1) return 'one'; + return 'other'; + }, + '9': function _(n) { + if (n === 0 || n != 1 && isBetween(n % 100, 1, 19)) return 'few'; + if (n == 1) return 'one'; + return 'other'; + }, + '10': function _(n) { + if (isBetween(n % 10, 2, 9) && !isBetween(n % 100, 11, 19)) return 'few'; + if (n % 10 == 1 && !isBetween(n % 100, 11, 19)) return 'one'; + return 'other'; + }, + '11': function _(n) { + if (isBetween(n % 10, 2, 4) && !isBetween(n % 100, 12, 14)) return 'few'; + if (n % 10 === 0 || isBetween(n % 10, 5, 9) || isBetween(n % 100, 11, 14)) return 'many'; + if (n % 10 == 1 && n % 100 != 11) return 'one'; + return 'other'; + }, + '12': function _(n) { + if (isBetween(n, 2, 4)) return 'few'; + if (n == 1) return 'one'; + return 'other'; + }, + '13': function _(n) { + if (isBetween(n % 10, 2, 4) && !isBetween(n % 100, 12, 14)) return 'few'; + if (n != 1 && isBetween(n % 10, 0, 1) || isBetween(n % 10, 5, 9) || isBetween(n % 100, 12, 14)) return 'many'; + if (n == 1) return 'one'; + return 'other'; + }, + '14': function _(n) { + if (isBetween(n % 100, 3, 4)) return 'few'; + if (n % 100 == 2) return 'two'; + if (n % 100 == 1) return 'one'; + return 'other'; + }, + '15': function _(n) { + if (n === 0 || isBetween(n % 100, 2, 10)) return 'few'; + if (isBetween(n % 100, 11, 19)) return 'many'; + if (n == 1) return 'one'; + return 'other'; + }, + '16': function _(n) { + if (n % 10 == 1 && n != 11) return 'one'; + return 'other'; + }, + '17': function _(n) { + if (n == 3) return 'few'; + if (n === 0) return 'zero'; + if (n == 6) return 'many'; + if (n == 2) return 'two'; + if (n == 1) return 'one'; + return 'other'; + }, + '18': function _(n) { + if (n === 0) return 'zero'; + if (isBetween(n, 0, 2) && n !== 0 && n != 2) return 'one'; + return 'other'; + }, + '19': function _(n) { + if (isBetween(n, 2, 10)) return 'few'; + if (isBetween(n, 0, 1)) return 'one'; + return 'other'; + }, + '20': function _(n) { + if ((isBetween(n % 10, 3, 4) || n % 10 == 9) && !(isBetween(n % 100, 10, 19) || isBetween(n % 100, 70, 79) || isBetween(n % 100, 90, 99))) return 'few'; + if (n % 1000000 === 0 && n !== 0) return 'many'; + if (n % 10 == 2 && !isIn(n % 100, [12, 72, 92])) return 'two'; + if (n % 10 == 1 && !isIn(n % 100, [11, 71, 91])) return 'one'; + return 'other'; + }, + '21': function _(n) { + if (n === 0) return 'zero'; + if (n == 1) return 'one'; + return 'other'; + }, + '22': function _(n) { + if (isBetween(n, 0, 1) || isBetween(n, 11, 99)) return 'one'; + return 'other'; + }, + '23': function _(n) { + if (isBetween(n % 10, 1, 2) || n % 20 === 0) return 'one'; + return 'other'; + }, + '24': function _(n) { + if (isBetween(n, 3, 10) || isBetween(n, 13, 19)) return 'few'; + if (isIn(n, [2, 12])) return 'two'; + if (isIn(n, [1, 11])) return 'one'; + return 'other'; + } + }; + var index = locales2rules[lang.replace(/-.*$/, '')]; + + if (!(index in pluralRules)) { + console.warn('plural form unknown for [' + lang + ']'); + return function () { + return 'other'; + }; + } + + return pluralRules[index]; + } + + gMacros.plural = function (str, param, key, prop) { + var n = parseFloat(param); + if (isNaN(n)) return str; + if (prop != gTextProp) return str; + + if (!gMacros._pluralRules) { + gMacros._pluralRules = getPluralRules(gLanguage); + } + + var index = '[' + gMacros._pluralRules(n) + ']'; + + if (n === 0 && key + '[zero]' in gL10nData) { + str = gL10nData[key + '[zero]'][prop]; + } else if (n == 1 && key + '[one]' in gL10nData) { + str = gL10nData[key + '[one]'][prop]; + } else if (n == 2 && key + '[two]' in gL10nData) { + str = gL10nData[key + '[two]'][prop]; + } else if (key + index in gL10nData) { + str = gL10nData[key + index][prop]; + } else if (key + '[other]' in gL10nData) { + str = gL10nData[key + '[other]'][prop]; + } + + return str; + }; + + function getL10nData(key, args, fallback) { + var data = gL10nData[key]; + + if (!data) { + console.warn('#' + key + ' is undefined.'); + + if (!fallback) { + return null; + } + + data = fallback; + } + + var rv = {}; + + for (var prop in data) { + var str = data[prop]; + str = substIndexes(str, args, key, prop); + str = substArguments(str, args, key); + rv[prop] = str; + } + + return rv; + } + + function substIndexes(str, args, key, prop) { + var reIndex = /\{\[\s*([a-zA-Z]+)\(([a-zA-Z]+)\)\s*\]\}/; + var reMatch = reIndex.exec(str); + if (!reMatch || !reMatch.length) return str; + var macroName = reMatch[1]; + var paramName = reMatch[2]; + var param; + + if (args && paramName in args) { + param = args[paramName]; + } else if (paramName in gL10nData) { + param = gL10nData[paramName]; + } + + if (macroName in gMacros) { + var macro = gMacros[macroName]; + str = macro(str, param, key, prop); + } + + return str; + } + + function substArguments(str, args, key) { + var reArgs = /\{\{\s*(.+?)\s*\}\}/g; + return str.replace(reArgs, function (matched_text, arg) { + if (args && arg in args) { + return args[arg]; + } + + if (arg in gL10nData) { + return gL10nData[arg]; + } + + console.log('argument {{' + arg + '}} for #' + key + ' is undefined.'); + return matched_text; + }); + } + + function translateElement(element) { + var l10n = getL10nAttributes(element); + if (!l10n.id) return; + var data = getL10nData(l10n.id, l10n.args); + + if (!data) { + console.warn('#' + l10n.id + ' is undefined.'); + return; + } + + if (data[gTextProp]) { + if (getChildElementCount(element) === 0) { + element[gTextProp] = data[gTextProp]; + } else { + var children = element.childNodes; + var found = false; + + for (var i = 0, l = children.length; i < l; i++) { + if (children[i].nodeType === 3 && /\S/.test(children[i].nodeValue)) { + if (found) { + children[i].nodeValue = ''; + } else { + children[i].nodeValue = data[gTextProp]; + found = true; + } + } + } + + if (!found) { + var textNode = document.createTextNode(data[gTextProp]); + element.insertBefore(textNode, element.firstChild); + } + } + + delete data[gTextProp]; + } + + for (var k in data) { + element[k] = data[k]; + } + } + + function getChildElementCount(element) { + if (element.children) { + return element.children.length; + } + + if (typeof element.childElementCount !== 'undefined') { + return element.childElementCount; + } + + var count = 0; + + for (var i = 0; i < element.childNodes.length; i++) { + count += element.nodeType === 1 ? 1 : 0; + } + + return count; + } + + function translateFragment(element) { + element = element || document.documentElement; + var children = getTranslatableChildren(element); + var elementCount = children.length; + + for (var i = 0; i < elementCount; i++) { + translateElement(children[i]); + } + + translateElement(element); + } + + return { + get: function get(key, args, fallbackString) { + var index = key.lastIndexOf('.'); + var prop = gTextProp; + + if (index > 0) { + prop = key.substring(index + 1); + key = key.substring(0, index); + } + + var fallback; + + if (fallbackString) { + fallback = {}; + fallback[prop] = fallbackString; + } + + var data = getL10nData(key, args, fallback); + + if (data && prop in data) { + return data[prop]; + } + + return '{{' + key + '}}'; + }, + getData: function getData() { + return gL10nData; + }, + getText: function getText() { + return gTextData; + }, + getLanguage: function getLanguage() { + return gLanguage; + }, + setLanguage: function setLanguage(lang, callback) { + loadLocale(lang, function () { + if (callback) callback(); + }); + }, + getDirection: function getDirection() { + var rtlList = ['ar', 'he', 'fa', 'ps', 'ur']; + var shortCode = gLanguage.split('-', 1)[0]; + return rtlList.indexOf(shortCode) >= 0 ? 'rtl' : 'ltr'; + }, + translate: translateFragment, + getReadyState: function getReadyState() { + return gReadyState; + }, + ready: function ready(callback) { + if (!callback) { + return; + } else if (gReadyState == 'complete' || gReadyState == 'interactive') { + window.setTimeout(function () { + callback(); + }); + } else if (document.addEventListener) { + document.addEventListener('localized', function once() { + document.removeEventListener('localized', once); + callback(); + }); + } + } + }; +}(window, document); + +/***/ }), +/* 42 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PDFPrintService = PDFPrintService; + +var _ui_utils = __webpack_require__(6); + +var _app = __webpack_require__(1); + +var _pdfjsLib = __webpack_require__(7); + +var activeService = null; +var overlayManager = null; + +function renderPage(activeServiceOnEntry, pdfDocument, pageNumber, size) { + var scratchCanvas = activeService.scratchCanvas; + var PRINT_RESOLUTION = 150; + var PRINT_UNITS = PRINT_RESOLUTION / 72.0; + scratchCanvas.width = Math.floor(size.width * PRINT_UNITS); + scratchCanvas.height = Math.floor(size.height * PRINT_UNITS); + var width = Math.floor(size.width * _ui_utils.CSS_UNITS) + 'px'; + var height = Math.floor(size.height * _ui_utils.CSS_UNITS) + 'px'; + var ctx = scratchCanvas.getContext('2d'); + ctx.save(); + ctx.fillStyle = 'rgb(255, 255, 255)'; + ctx.fillRect(0, 0, scratchCanvas.width, scratchCanvas.height); + ctx.restore(); + return pdfDocument.getPage(pageNumber).then(function (pdfPage) { + var renderContext = { + canvasContext: ctx, + transform: [PRINT_UNITS, 0, 0, PRINT_UNITS, 0, 0], + viewport: pdfPage.getViewport({ + scale: 1, + rotation: size.rotation + }), + intent: 'print' + }; + return pdfPage.render(renderContext).promise; + }).then(function () { + return { + width: width, + height: height + }; + }); +} + +function PDFPrintService(pdfDocument, pagesOverview, printContainer, l10n) { + this.pdfDocument = pdfDocument; + this.pagesOverview = pagesOverview; + this.printContainer = printContainer; + this.l10n = l10n || _ui_utils.NullL10n; + this.disableCreateObjectURL = pdfDocument.loadingParams['disableCreateObjectURL']; + this.currentPage = -1; + this.scratchCanvas = document.createElement('canvas'); +} + +PDFPrintService.prototype = { + layout: function layout() { + this.throwIfInactive(); + var body = document.querySelector('body'); + body.setAttribute('data-pdfjsprinting', true); + var hasEqualPageSizes = this.pagesOverview.every(function (size) { + return size.width === this.pagesOverview[0].width && size.height === this.pagesOverview[0].height; + }, this); + + if (!hasEqualPageSizes) { + console.warn('Not all pages have the same size. The printed ' + 'result may be incorrect!'); + } + + this.pageStyleSheet = document.createElement('style'); + var pageSize = this.pagesOverview[0]; + this.pageStyleSheet.textContent = '@supports ((size:A4) and (size:1pt 1pt)) {' + '@page { size: ' + pageSize.width + 'pt ' + pageSize.height + 'pt;}' + '}'; + body.appendChild(this.pageStyleSheet); + }, + destroy: function destroy() { + if (activeService !== this) { + return; + } + + this.printContainer.textContent = ''; + + if (this.pageStyleSheet) { + this.pageStyleSheet.remove(); + this.pageStyleSheet = null; + } + + this.scratchCanvas.width = this.scratchCanvas.height = 0; + this.scratchCanvas = null; + activeService = null; + ensureOverlay().then(function () { + if (overlayManager.active !== 'printServiceOverlay') { + return; + } + + overlayManager.close('printServiceOverlay'); + }); + }, + renderPages: function renderPages() { + var _this = this; + + var pageCount = this.pagesOverview.length; + + var renderNextPage = function renderNextPage(resolve, reject) { + _this.throwIfInactive(); + + if (++_this.currentPage >= pageCount) { + renderProgress(pageCount, pageCount, _this.l10n); + resolve(); + return; + } + + var index = _this.currentPage; + renderProgress(index, pageCount, _this.l10n); + renderPage(_this, _this.pdfDocument, index + 1, _this.pagesOverview[index]).then(_this.useRenderedPage.bind(_this)).then(function () { + renderNextPage(resolve, reject); + }, reject); + }; + + return new Promise(renderNextPage); + }, + useRenderedPage: function useRenderedPage(printItem) { + this.throwIfInactive(); + var img = document.createElement('img'); + img.style.width = printItem.width; + img.style.height = printItem.height; + var scratchCanvas = this.scratchCanvas; + + if ('toBlob' in scratchCanvas && !this.disableCreateObjectURL) { + scratchCanvas.toBlob(function (blob) { + img.src = _pdfjsLib.URL.createObjectURL(blob); + }); + } else { + img.src = scratchCanvas.toDataURL(); + } + + var wrapper = document.createElement('div'); + wrapper.appendChild(img); + this.printContainer.appendChild(wrapper); + return new Promise(function (resolve, reject) { + img.onload = resolve; + img.onerror = reject; + }); + }, + performPrint: function performPrint() { + var _this2 = this; + + this.throwIfInactive(); + return new Promise(function (resolve) { + setTimeout(function () { + if (!_this2.active) { + resolve(); + return; + } + + print.call(window); + setTimeout(resolve, 20); + }, 0); + }); + }, + + get active() { + return this === activeService; + }, + + throwIfInactive: function throwIfInactive() { + if (!this.active) { + throw new Error('This print request was cancelled or completed.'); + } + } +}; +var print = window.print; + +window.print = function print() { + if (activeService) { + console.warn('Ignored window.print() because of a pending print job.'); + return; + } + + ensureOverlay().then(function () { + if (activeService) { + overlayManager.open('printServiceOverlay'); + } + }); + + try { + dispatchEvent('beforeprint'); + } finally { + if (!activeService) { + console.error('Expected print service to be initialized.'); + ensureOverlay().then(function () { + if (overlayManager.active === 'printServiceOverlay') { + overlayManager.close('printServiceOverlay'); + } + }); + return; + } + + var activeServiceOnEntry = activeService; + activeService.renderPages().then(function () { + return activeServiceOnEntry.performPrint(); + }).catch(function () {}).then(function () { + if (activeServiceOnEntry.active) { + abort(); + } + }); + } +}; + +function dispatchEvent(eventType) { + var event = document.createEvent('CustomEvent'); + event.initCustomEvent(eventType, false, false, 'custom'); + window.dispatchEvent(event); +} + +function abort() { + if (activeService) { + activeService.destroy(); + dispatchEvent('afterprint'); + } +} + +function renderProgress(index, total, l10n) { + var progressContainer = document.getElementById('printServiceOverlay'); + var progress = Math.round(100 * index / total); + var progressBar = progressContainer.querySelector('progress'); + var progressPerc = progressContainer.querySelector('.relative-progress'); + progressBar.value = progress; + l10n.get('print_progress_percent', { + progress: progress + }, progress + '%').then(function (msg) { + progressPerc.textContent = msg; + }); +} + +var hasAttachEvent = !!document.attachEvent; +window.addEventListener('keydown', function (event) { + if (event.keyCode === 80 && (event.ctrlKey || event.metaKey) && !event.altKey && (!event.shiftKey || window.chrome || window.opera)) { + window.print(); + + if (hasAttachEvent) { + return; + } + + event.preventDefault(); + + if (event.stopImmediatePropagation) { + event.stopImmediatePropagation(); + } else { + event.stopPropagation(); + } + + return; + } +}, true); + +if (hasAttachEvent) { + document.attachEvent('onkeydown', function (event) { + event = event || window.event; + + if (event.keyCode === 80 && event.ctrlKey) { + event.keyCode = 0; + return false; + } + }); +} + +if ('onbeforeprint' in window) { + var stopPropagationIfNeeded = function stopPropagationIfNeeded(event) { + if (event.detail !== 'custom' && event.stopImmediatePropagation) { + event.stopImmediatePropagation(); + } + }; + + window.addEventListener('beforeprint', stopPropagationIfNeeded); + window.addEventListener('afterprint', stopPropagationIfNeeded); +} + +var overlayPromise; + +function ensureOverlay() { + if (!overlayPromise) { + overlayManager = _app.PDFViewerApplication.overlayManager; + + if (!overlayManager) { + throw new Error('The overlay manager has not yet been initialized.'); + } + + overlayPromise = overlayManager.register('printServiceOverlay', document.getElementById('printServiceOverlay'), abort, true); + document.getElementById('printCancel').onclick = abort; + } + + return overlayPromise; +} + +_app.PDFPrintServiceFactory.instance = { + supportsPrinting: true, + createPrintService: function createPrintService(pdfDocument, pagesOverview, printContainer, l10n) { + if (activeService) { + throw new Error('The print service is created and active.'); + } + + activeService = new PDFPrintService(pdfDocument, pagesOverview, printContainer, l10n); + return activeService; + } +}; + +/***/ }) +/******/ ]); +//# sourceMappingURL=viewer.js.map diff --git a/pdf_print_preview/pdf_print_preview/static/lib/pdfjs/web/viewer.js_Zone.Identifier b/pdf_print_preview/pdf_print_preview/static/lib/pdfjs/web/viewer.js_Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/pdf_print_preview/pdf_print_preview/static/src/js/pdf_preview.js b/pdf_print_preview/pdf_print_preview/static/src/js/pdf_preview.js new file mode 100644 index 0000000..740b9bb --- /dev/null +++ b/pdf_print_preview/pdf_print_preview/static/src/js/pdf_preview.js @@ -0,0 +1,185 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { _t } from "@web/core/l10n/translation"; +import { session } from "@web/session"; +import { rpc } from "@web/core/network/rpc"; +import { user } from "@web/core/user"; +import { Component, useState, onMounted } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; + +export class PDFViewerDialog extends Component { + setup() { + this.state = useState({ + isLoading: true, + viewerUrl: this.getViewerUrl(), + isMaximized: false + }); + } + + getViewerUrl() { + const baseUrl = '/pdf_print_preview/static/lib/pdfjs/web/viewer.html'; + return `${baseUrl}?file=${this.props.url}`; + } + + onIframeLoad() { + this.state.isLoading = false; + } + + toggle() { + this.state.isMaximized = !this.state.isMaximized; + } + + getDialogSize() { + if (this.state.isMaximized) { + return 'fullscreen'; + } + return 'xl'; + } + + getFrameStyle() { + if (this.state.isMaximized) { + return 'height: calc(98vh - 141px) !important;'; + } + return 'height: calc(90vh - 100px) !important;'; + } +} + +PDFViewerDialog.template = 'pdf_print_preview.PDFViewerDialog'; +PDFViewerDialog.components = { Dialog }; + +// Register for use in actions +registry.category("dialog").add("PDFViewerDialog", PDFViewerDialog); + + +export function openPDFViewer(env, url, title = "PDF Document") { + const dialog = env.services.dialog; + return dialog.add(PDFViewerDialog, { + url: url, + title: title + }); +} + + +/** + * Helper function to handle automatic printing + * @param {string} url - URL of the PDF to print + * @param {Object} env - Environment object for notifications + */ +function handleAutomaticPrinting(url, env) { + const printFrame = document.createElement('iframe'); + printFrame.style.display = 'none'; + printFrame.src = url; + + printFrame.onload = function() { + try { + printFrame.contentWindow.print(); + } catch (err) { + env.services.notification.add( + _t("Failed to print automatically. Please check your browser settings."), + { + type: 'warning', + sticky: true, + title: _t("Printing Error"), + } + ); + document.body.removeChild(printFrame); + } + }; + + const cleanup = () => { + document.body.removeChild(printFrame); + window.removeEventListener('focus', cleanup); + }; + + window.addEventListener('focus', cleanup); + + document.body.appendChild(printFrame); +} + + +/** + * Generates the report url given a report action. + * + * @private + * @param {ReportAction} action + * @param {env} env + * @returns {string} + */ +function _getReportUrl(action, env, filename) { + let url = `/report/pdf/${action.report_name}`; + const actionContext = action.context || {}; + filename = filename || action.name; + if(filename !== undefined) + filename = filename.replace(/[/?%#&=]/g, "_") + ".pdf"; + if (action.data && JSON.stringify(action.data) !== "{}") { + const options = encodeURIComponent(JSON.stringify(action.data)); + const context = encodeURIComponent(JSON.stringify(actionContext)); + url += `?filename=${filename}&options=${options}&context=${context}&`; + } else { + if (actionContext.active_ids) { + url += `/${actionContext.active_ids.join(",")}?filename=${filename}&context=${encodeURIComponent(JSON.stringify(user.context))}&`; + } + } + + return url; +} + +async function PdfPrintPreview(action, options, env) { + const link = '

    wkhtmltopdf.org'; + const WKHTMLTOPDF_MESSAGES = { + broken: + _t( + "Your installation of Wkhtmltopdf seems to be broken. The report will be shown " + + "in html." + ) + link, + install: + _t( + "Unable to find Wkhtmltopdf on this system. The report will be shown in " + "html." + ) + link, + upgrade: + _t( + "You should upgrade your version of Wkhtmltopdf to at least 0.12.0 in order to " + + "get a correct display of headers and footers as well as support for " + + "table-breaking between pages." + ) + link, + workers: _t( + "You need to start Odoo with at least two workers to print a pdf version of " + + "the reports." + ), + }; + + if (action.report_type === "qweb-pdf" && env.services.menu.getCurrentApp() !== undefined && (session.preview_print || session.automatic_printing)) { + let getReportResult = rpc("/pdf_print_preview/get_report_name", { + report_name: action.report_name, + data: JSON.stringify(action.context) + }); + const result = await getReportResult; + const state = result["wkhtmltopdf_state"]; + + // display a notification according to wkhtmltopdf's state + if (state in WKHTMLTOPDF_MESSAGES) { + env.services.notification.add(WKHTMLTOPDF_MESSAGES[state], { + sticky: true, + title: _t("Report"), + }); + } + + if (state === "upgrade" || state === "ok") { + let url = _getReportUrl(action, env, result["file_name"]); + if(session.preview_print) { + // PreviewDialog.createPreviewDialog(self, url, action.name); + openPDFViewer(env, url, action.name); + } + + if (session.automatic_printing) { + handleAutomaticPrinting(url, env); + } + return true; + } + } +} + +registry + .category("ir.actions.report handlers") + .add("pdf_print_preview", PdfPrintPreview); diff --git a/pdf_print_preview/pdf_print_preview/static/src/js/user_menu.js b/pdf_print_preview/pdf_print_preview/static/src/js/user_menu.js new file mode 100644 index 0000000..f2df2ac --- /dev/null +++ b/pdf_print_preview/pdf_print_preview/static/src/js/user_menu.js @@ -0,0 +1,26 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { _t } from "@web/core/l10n/translation"; +import { rpc } from "@web/core/network/rpc"; +import { user } from "@web/core/user"; + + +function reportPreviewConfigItem(env) { + return { + type: "item", + id: "report_preview", + description: _t("Report preview"), + callback: async function () { + const actionDescription = await rpc("/web/action/load", { + action_id: "pdf_print_preview.action_short_preview_print" + }); + actionDescription.res_id = user.userId; + env.services.action.doAction(actionDescription); + }, + sequence: 52, + }; +} + +registry.category("user_menuitems") + .add("report_preview", reportPreviewConfigItem); diff --git a/pdf_print_preview/pdf_print_preview/static/src/xml/pdf_viewer_dialog.xml b/pdf_print_preview/pdf_print_preview/static/src/xml/pdf_viewer_dialog.xml new file mode 100644 index 0000000..604e61b --- /dev/null +++ b/pdf_print_preview/pdf_print_preview/static/src/xml/pdf_viewer_dialog.xml @@ -0,0 +1,41 @@ + + + + + +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    + Loading... +
    +
    + + +