diff --git a/fusion_helpdesk/__init__.py b/fusion_helpdesk/__init__.py new file mode 100644 index 00000000..9e5827f9 --- /dev/null +++ b/fusion_helpdesk/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import controllers +from . import models diff --git a/fusion_helpdesk/__manifest__.py b/fusion_helpdesk/__manifest__.py new file mode 100644 index 00000000..8560f27c --- /dev/null +++ b/fusion_helpdesk/__manifest__.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +{ + 'name': 'Fusion Helpdesk Reporter', + 'version': '19.0.1.2.0', + 'category': 'Productivity', + 'summary': 'One-click in-app bug reporting & feature requesting — ' + 'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.', + 'description': """ +Fusion Helpdesk Reporter +======================== +A standalone module that gives every backend user a quick "Report an +Issue / Request a Feature" button in the Odoo top systray. + +Submissions are forwarded over JSON-RPC / XML-RPC to a central Odoo +instance (typically the support shop's main Odoo) and become +`helpdesk.ticket` records with screenshots, file attachments, and +diagnostic context (URL, user agent, current user/company, error +code) automatically captured. + +Designed to ship alongside any other Fusion / Nexa Systems client +module bundle. No dependencies on the rest of Fusion Plating. + """, + 'author': 'Nexa Systems Inc.', + 'website': 'https://www.nexasystems.ca', + 'license': 'OPL-1', + 'depends': ['base', 'web', 'mail'], + 'data': [ + 'security/ir.model.access.csv', + 'data/ir_config_parameter_data.xml', + 'views/res_config_settings_views.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'fusion_helpdesk/static/src/scss/fusion_helpdesk.scss', + 'fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml', + 'fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml', + 'fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js', + 'fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js', + ], + }, + 'images': ['static/description/icon.png'], + 'installable': True, + 'auto_install': False, + 'application': False, +} diff --git a/fusion_helpdesk/controllers/__init__.py b/fusion_helpdesk/controllers/__init__.py new file mode 100644 index 00000000..757b12a1 --- /dev/null +++ b/fusion_helpdesk/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import main diff --git a/fusion_helpdesk/controllers/main.py b/fusion_helpdesk/controllers/main.py new file mode 100644 index 00000000..b5ad514f --- /dev/null +++ b/fusion_helpdesk/controllers/main.py @@ -0,0 +1,402 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +"""HTTP routes for the Fusion Helpdesk Reporter. + +`/fusion_helpdesk/submit` accepts the dialog payload, forwards it to a +remote Odoo Helpdesk over XML-RPC, attaches uploaded files + +screenshot, and returns the resulting ticket id/url to the OWL dialog. + +Why XML-RPC and not JSON-RPC? Helpdesk's external API surface is +exposed via Odoo's standard `/xmlrpc/2/object` endpoint which is +the most stable cross-version contract. JSON-RPC `/jsonrpc` works +too but historically had quirkier error handling on file payloads. +""" +import base64 +import logging +import socket +import ssl +import xmlrpc.client +from urllib.parse import urljoin, urlparse + +from odoo import _, http +from odoo.exceptions import UserError +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class FusionHelpdeskController(http.Controller): + + @http.route( + '/fusion_helpdesk/submit', + type='jsonrpc', auth='user', methods=['POST'], + ) + def submit(self, kind, subject, description, + error_code=None, attachments=None, + page_url=None, user_agent=None): + """Forward a bug report or feature request to the central Odoo + Helpdesk and return {ok, ticket_id, ticket_url, error}. + + Args: + kind: 'bug' or 'feature'. + subject: short title. + description: long-form. May contain HTML or plain text. + error_code: optional traceback / error string. + attachments: list of {name, mimetype, data_b64} dicts. + page_url: client-side window.location.href at submit time. + user_agent: client-side navigator.userAgent. + """ + cfg = self._read_config() + if not all([cfg['url'], cfg['db'], cfg['login'], cfg['password']]): + return { + 'ok': False, + 'error': 'config_missing', + 'message': _( + 'Fusion Helpdesk is not fully configured. Ask an ' + 'administrator to fill in the remote URL, database, ' + 'login and password under Settings → Fusion Helpdesk.' + ), + } + + # ---- Build the ticket payload --------------------------------- + prefix = ('[%s] ' % cfg['client_label']) if cfg['client_label'] else '' + kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request' + full_subject = '%s%s: %s' % (prefix, kind_label, subject or '(untitled)') + + body_parts = [] + if description: + body_parts.append( + '

Description

%s
' % _html_escape(description) + ) + if error_code: + body_parts.append( + '

Error Code / Traceback

' + '
%s
' % _html_escape(error_code) + ) + body_parts.append(self._build_diag_block(page_url, user_agent)) + + ticket_vals = { + 'name': full_subject, + 'description': '\n'.join(body_parts), + } + if cfg['team_id']: + ticket_vals['team_id'] = cfg['team_id'] + + # ---- Talk to remote Odoo -------------------------------------- + try: + uid, models_proxy = self._authenticate(cfg) + except _RemoteError as e: + return e.to_response() + + try: + ticket_id = models_proxy.execute_kw( + cfg['db'], uid, cfg['password'], + 'helpdesk.ticket', 'create', [ticket_vals], + ) + except xmlrpc.client.Fault as e: + fault = (e.faultString or '').strip() + _logger.warning( + 'fusion_helpdesk: helpdesk.ticket.create failed: %s', + fault, + ) + # The Helpdesk app might not be installed on the remote. + if 'helpdesk.ticket' in fault and 'does not exist' in fault.lower(): + return { + 'ok': False, 'error': 'helpdesk_not_installed', + 'message': _( + 'The Helpdesk app is not installed on the central ' + 'Odoo at %s. Install the Helpdesk app there before ' + 'submitting tickets.' + ) % cfg['url'], + } + if 'access' in fault.lower() or 'rights' in fault.lower(): + return { + 'ok': False, 'error': 'permission_denied', + 'message': _( + 'The remote service account "%s" does not have ' + 'permission to create helpdesk tickets. Ask a ' + 'central Odoo admin to grant Helpdesk Officer ' + 'rights to that user.' + ) % cfg['login'], + } + return { + 'ok': False, 'error': 'create_failed', + 'message': _('The remote Helpdesk rejected the ticket: %s' + ) % fault, + } + except (socket.timeout, OSError, ssl.SSLError) as e: + _logger.warning( + 'fusion_helpdesk: ticket create network error: %s', e, + ) + return _network_error_response(cfg['url'], e) + + # ---- Push attachments ----------------------------------------- + attached = 0 + for att in attachments or []: + data_b64 = (att or {}).get('data_b64') + name = (att or {}).get('name') or 'attachment.bin' + mimetype = (att or {}).get('mimetype') or 'application/octet-stream' + if not data_b64: + continue + try: + models_proxy.execute_kw( + cfg['db'], uid, cfg['password'], + 'ir.attachment', 'create', [{ + 'name': name, + 'datas': data_b64, + 'res_model': 'helpdesk.ticket', + 'res_id': ticket_id, + 'mimetype': mimetype, + }], + ) + attached += 1 + except xmlrpc.client.Fault as e: + _logger.warning( + 'fusion_helpdesk: attachment "%s" upload failed: %s', + name, e.faultString, + ) + + ticket_url = urljoin( + cfg['url'].rstrip('/') + '/', + 'odoo/helpdesk/%s' % ticket_id, + ) + _logger.info( + 'fusion_helpdesk: created remote ticket #%s (%s attachments) ' + 'on %s for user %s', + ticket_id, attached, cfg['url'], + request.env.user.login, + ) + return { + 'ok': True, + 'ticket_id': ticket_id, + 'ticket_url': ticket_url, + 'attached': attached, + } + + # ------------------------------------------------------------------ + def _read_config(self): + """Return the active config as a plain dict. Run as sudo so + regular users can submit even without read-access on + ir.config_parameter (the password row is system-write but + readable by anyone with backend access).""" + ICP = request.env['ir.config_parameter'].sudo() + return { + 'url': (ICP.get_param('fusion_helpdesk.remote_url') or '').strip(), + 'db': (ICP.get_param('fusion_helpdesk.remote_db') or '').strip(), + 'login': (ICP.get_param('fusion_helpdesk.remote_login') or '').strip(), + 'password': ICP.get_param('fusion_helpdesk.remote_password') or '', + 'team_id': int( + ICP.get_param('fusion_helpdesk.remote_team_id') or 0 + ) or False, + 'client_label': ( + ICP.get_param('fusion_helpdesk.client_label') or '' + ).strip(), + } + + def _authenticate(self, cfg): + """Authenticate against the remote and return (uid, models_proxy). + + Raises _RemoteError with a granular `error` code and a friendly + end-user message so the dialog can show what actually broke + (network vs. credentials vs. server problem). + """ + url = cfg['url'].rstrip('/') + try: + common = xmlrpc.client.ServerProxy( + '%s/xmlrpc/2/common' % url, allow_none=True, + ) + try: + uid = common.authenticate( + cfg['db'], cfg['login'], cfg['password'], {}, + ) + except xmlrpc.client.ProtocolError as e: + # Server returned an HTTP error status. + _logger.warning( + 'fusion_helpdesk: HTTP %s from %s during authenticate: %s', + e.errcode, url, e.errmsg, + ) + if e.errcode in (401, 403): + raise _RemoteError( + 'auth_failed', + _('The central Odoo at %s rejected the login. ' + 'Check the Service Login and API Key in ' + 'Settings → Fusion Helpdesk.') % url, + ) + if e.errcode == 404: + raise _RemoteError( + 'endpoint_not_found', + _('The XML-RPC endpoint at %s/xmlrpc/2/common ' + 'returned 404. Verify the Remote URL points ' + 'at an Odoo server (no trailing path).') % url, + ) + raise _RemoteError( + 'remote_http_error', + _('The central Odoo returned HTTP %(code)s while ' + 'authenticating. Try again, or check that ' + '%(url)s is reachable from a browser.' + ) % {'code': e.errcode, 'url': url}, + ) + except xmlrpc.client.Fault as e: + # Server-side application error — usually wrong DB name. + fault = (e.faultString or '').strip() + _logger.warning( + 'fusion_helpdesk: XML-RPC fault during authenticate: %s', + fault, + ) + if 'database' in fault.lower() and ( + 'not exist' in fault.lower() or 'unknown' in fault.lower() + ): + raise _RemoteError( + 'wrong_database', + _('Database "%(db)s" does not exist on %(url)s. ' + 'Verify the Remote DB in Settings → Fusion ' + 'Helpdesk.') % {'db': cfg['db'], 'url': url}, + ) + raise _RemoteError( + 'auth_failed', + _('The central Odoo rejected authentication: %s' + ) % fault, + ) + except ssl.SSLError as e: + _logger.warning( + 'fusion_helpdesk: TLS error against %s: %s', url, e, + ) + raise _RemoteError( + 'tls_error', + _('TLS / SSL handshake with %(url)s failed: ' + '%(msg)s. Either the server cert is invalid or ' + 'the system cert store on this host is out of ' + 'date.') % {'url': url, 'msg': str(e)}, + ) + except socket.gaierror as e: + _logger.warning( + 'fusion_helpdesk: DNS lookup failed for %s: %s', url, e, + ) + raise _RemoteError( + 'dns_error', + _('Could not resolve "%(host)s". Check Settings → ' + 'Fusion Helpdesk → Remote URL, or your server\'s ' + 'DNS / outbound network.' + ) % {'host': urlparse(url).hostname or url}, + ) + except (ConnectionRefusedError, socket.timeout) as e: + _logger.warning( + 'fusion_helpdesk: connection problem to %s: %s', url, e, + ) + raise _RemoteError( + 'unreachable', + _('Could not reach %(url)s — connection refused or ' + 'timed out. Check that this server can make ' + 'outbound HTTPS to %(host)s (firewall, proxy).' + ) % {'url': url, 'host': urlparse(url).hostname or url}, + ) + except OSError as e: + # Catch-all for socket / network issues we didn't classify. + _logger.warning( + 'fusion_helpdesk: network error to %s: %s', url, e, + ) + raise _RemoteError( + 'unreachable', + _('Network error reaching %(url)s: %(msg)s. Verify ' + 'this server has outbound internet access.' + ) % {'url': url, 'msg': str(e)}, + ) + except _RemoteError: + raise + except Exception as e: + _logger.exception( + 'fusion_helpdesk: unexpected error during authenticate ' + 'against %s', url, + ) + raise _RemoteError( + 'unknown_error', + _('Unexpected error contacting the central Helpdesk: %s' + ) % str(e), + ) + + if not uid: + raise _RemoteError( + 'auth_failed', + _('The central Odoo at %(url)s did not accept the ' + 'login "%(login)s". Verify the Service Login and ' + 'API Key in Settings → Fusion Helpdesk.' + ) % {'url': url, 'login': cfg['login']}, + ) + + models_proxy = xmlrpc.client.ServerProxy( + '%s/xmlrpc/2/object' % url, allow_none=True, + ) + return uid, models_proxy + + def _build_diag_block(self, page_url, user_agent): + env = request.env + company = env.company + user = env.user + rows = [ + ('User', '%s (#%s, %s)' % (user.name, user.id, user.login)), + ('Company', '%s (#%s)' % (company.name, company.id)), + ('Source page', page_url or '—'), + ('User agent', user_agent or '—'), + ('Source DB', request.env.cr.dbname), + ('Source host', request.httprequest.host_url), + ] + body = '

Diagnostic context

' + for k, v in rows: + body += ( + '' + '' + ) % (_html_escape(k), _html_escape(str(v))) + body += '
%s%s
' + return body + + +def _html_escape(s): + return ( + (s or '') + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + ) + + +class _RemoteError(Exception): + """Typed wrapper that carries an `error` code + a friendly message + so the dialog can show the right thing.""" + def __init__(self, code, message): + super().__init__(message) + self.code = code + self.message = message + + def to_response(self): + return {'ok': False, 'error': self.code, 'message': self.message} + + +def _network_error_response(url, err): + """Map a raw network exception raised mid-RPC into the same + response shape the dialog already knows how to render.""" + host = urlparse(url).hostname or url + if isinstance(err, ssl.SSLError): + return { + 'ok': False, 'error': 'tls_error', + 'message': _('TLS / SSL error talking to %(url)s: %(msg)s' + ) % {'url': url, 'msg': str(err)}, + } + if isinstance(err, socket.gaierror): + return { + 'ok': False, 'error': 'dns_error', + 'message': _('Could not resolve "%s" — check DNS / outbound network.' + ) % host, + } + if isinstance(err, socket.timeout): + return { + 'ok': False, 'error': 'unreachable', + 'message': _('Timed out talking to %s — check the firewall ' + 'allows outbound HTTPS.') % url, + } + return { + 'ok': False, 'error': 'unreachable', + 'message': _('Network error talking to %(url)s: %(msg)s' + ) % {'url': url, 'msg': str(err)}, + } diff --git a/fusion_helpdesk/data/ir_config_parameter_data.xml b/fusion_helpdesk/data/ir_config_parameter_data.xml new file mode 100644 index 00000000..0ef084e8 --- /dev/null +++ b/fusion_helpdesk/data/ir_config_parameter_data.xml @@ -0,0 +1,32 @@ + + + + + + fusion_helpdesk.remote_url + https://erp.nexasystems.ca + + + + fusion_helpdesk.remote_db + nexamain + + + + fusion_helpdesk.remote_login + helpdesk_bot@nexasystems.ca + + + + fusion_helpdesk.client_label + + + + diff --git a/fusion_helpdesk/models/__init__.py b/fusion_helpdesk/models/__init__.py new file mode 100644 index 00000000..6084d2ca --- /dev/null +++ b/fusion_helpdesk/models/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import res_config_settings diff --git a/fusion_helpdesk/models/res_config_settings.py b/fusion_helpdesk/models/res_config_settings.py new file mode 100644 index 00000000..91aef7e6 --- /dev/null +++ b/fusion_helpdesk/models/res_config_settings.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +"""Configuration for the Fusion Helpdesk Reporter. + +Stores the central Odoo Helpdesk endpoint that submissions are +forwarded to. Defaults point at erp.nexasystems.ca / nexamain; +each client deployment can override per system parameter. +""" +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + fhd_remote_url = fields.Char( + string='Helpdesk Remote URL', + config_parameter='fusion_helpdesk.remote_url', + help='Base URL of the central Odoo running the Helpdesk app, ' + 'e.g. https://erp.nexasystems.ca', + ) + fhd_remote_db = fields.Char( + string='Helpdesk Remote DB', + config_parameter='fusion_helpdesk.remote_db', + help='Database name on the remote Odoo (e.g. nexamain).', + ) + fhd_remote_login = fields.Char( + string='Helpdesk Remote Login', + config_parameter='fusion_helpdesk.remote_login', + help='Service-account login on the remote Odoo. Needs create ' + 'rights on helpdesk.ticket and ir.attachment.', + ) + fhd_remote_password = fields.Char( + string='Helpdesk Remote Password / API Key', + config_parameter='fusion_helpdesk.remote_password', + help='Service-account password or API key. Stored in ' + 'ir.config_parameter — restrict read access if needed.', + ) + fhd_remote_team_id = fields.Integer( + string='Helpdesk Team ID', + config_parameter='fusion_helpdesk.remote_team_id', + help='Optional. ID of the helpdesk.team on the remote that ' + 'should own all incoming tickets. Leave blank to use ' + 'the remote default routing.', + ) + fhd_client_label = fields.Char( + string='Client Label (auto-prepended to subject)', + config_parameter='fusion_helpdesk.client_label', + help='Short tag prefixed onto the ticket subject so support ' + 'can tell which client deployment a ticket came from. ' + 'e.g. "ENTECH" → "[ENTECH] My subject"', + ) diff --git a/fusion_helpdesk/security/ir.model.access.csv b/fusion_helpdesk/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/fusion_helpdesk/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/fusion_helpdesk/static/description/icon.png b/fusion_helpdesk/static/description/icon.png new file mode 100644 index 00000000..20f0f3f6 Binary files /dev/null and b/fusion_helpdesk/static/description/icon.png differ diff --git a/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js b/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js new file mode 100644 index 00000000..486e5351 --- /dev/null +++ b/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js @@ -0,0 +1,244 @@ +/** @odoo-module **/ +// Fusion Helpdesk — submission dialog. Lets the user pick Bug or +// Feature, fill in subject + description, paste an error code, attach +// files, and capture a screenshot via the browser's getDisplayMedia +// API. On submit, the payload is POSTed to /fusion_helpdesk/submit +// which forwards it (XML-RPC) to a central Odoo Helpdesk. + +import { Component, useState } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; +import { rpc } from "@web/core/network/rpc"; +import { useService } from "@web/core/utils/hooks"; +import { _t } from "@web/core/l10n/translation"; + +const MAX_BYTES_PER_FILE = 10 * 1024 * 1024; // 10 MB hard cap per file + +export class FusionHelpdeskDialog extends Component { + static template = "fusion_helpdesk.Dialog"; + static components = { Dialog }; + static props = { + close: Function, + }; + + setup() { + this.notification = useService("notification"); + this.state = useState({ + kind: "bug", // 'bug' | 'feature' + subject: "", + description: "", + errorCode: "", + attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}] + capturing: false, + submitting: false, + error: "", + success: false, + ticketId: null, + ticketUrl: "", + attached: 0, + }); + } + + get dialogTitle() { + return this.state.kind === "bug" + ? _t("Report a Bug") + : _t("Request a Feature"); + } + + setKind(kind) { + this.state.kind = kind; + } + + // ------------------------------------------------------------------ + // File input → b64 + async onFilesPicked(ev) { + const files = Array.from(ev.target.files || []); + for (const f of files) { + if (f.size > MAX_BYTES_PER_FILE) { + this.notification.add( + _t("File '%s' is over 10 MB and was skipped.").replace("%s", f.name), + { type: "warning" } + ); + continue; + } + try { + const b64 = await this._fileToB64(f); + this._addAttachment({ + name: f.name, + mimetype: f.type || "application/octet-stream", + data_b64: b64, + rawSize: f.size, + }); + } catch (err) { + this.notification.add( + _t("Could not read '%s'.").replace("%s", f.name), + { type: "danger" } + ); + } + } + // Reset the input so picking the same file again re-fires onchange. + ev.target.value = ""; + } + + _fileToB64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result || ""; + const idx = result.indexOf(","); + resolve(idx >= 0 ? result.slice(idx + 1) : result); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + // ------------------------------------------------------------------ + // Screenshot capture via getDisplayMedia + async onTakeScreenshot() { + if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { + this.notification.add( + _t("Your browser doesn't support screen capture. Use Attach files instead."), + { type: "warning" } + ); + return; + } + this.state.capturing = true; + let stream; + try { + stream = await navigator.mediaDevices.getDisplayMedia({ + video: { displaySurface: "browser" }, + audio: false, + }); + const blob = await this._streamToBlob(stream); + const b64 = await this._blobToB64(blob); + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + this._addAttachment({ + name: `screenshot-${ts}.png`, + mimetype: "image/png", + data_b64: b64, + rawSize: blob.size, + }); + } catch (err) { + // User cancelled the picker — silently swallow. Other errors → notify. + if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") { + this.notification.add( + _t("Screenshot failed: %s").replace("%s", err.message || err), + { type: "danger" } + ); + } + } finally { + if (stream) { + stream.getTracks().forEach((t) => t.stop()); + } + this.state.capturing = false; + } + } + + async _streamToBlob(stream) { + const video = document.createElement("video"); + video.srcObject = stream; + await video.play(); + // Give the browser one frame to settle the picker chrome. + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext("2d"); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + return await new Promise((resolve) => + canvas.toBlob((b) => resolve(b), "image/png", 0.92) + ); + } + + _blobToB64(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result || ""; + const idx = result.indexOf(","); + resolve(idx >= 0 ? result.slice(idx + 1) : result); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } + + _addAttachment({ name, mimetype, data_b64, rawSize }) { + this.state.attachments.push({ + name, + mimetype, + data_b64, + sizeLabel: this._formatBytes(rawSize), + iconClass: this._iconForMime(mimetype), + }); + } + + removeAttachment(idx) { + this.state.attachments.splice(idx, 1); + } + + _formatBytes(n) { + if (!n) return ""; + if (n < 1024) return n + " B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"; + return (n / (1024 * 1024)).toFixed(1) + " MB"; + } + + _iconForMime(mt) { + mt = (mt || "").toLowerCase(); + if (mt.startsWith("image/")) return "fa fa-file-image-o"; + if (mt.startsWith("video/")) return "fa fa-file-video-o"; + if (mt.startsWith("audio/")) return "fa fa-file-audio-o"; + if (mt.includes("pdf")) return "fa fa-file-pdf-o"; + if (mt.includes("zip") || mt.includes("tar") || mt.includes("rar")) return "fa fa-file-archive-o"; + if (mt.includes("text")) return "fa fa-file-text-o"; + return "fa fa-file-o"; + } + + // ------------------------------------------------------------------ + // Submit + async onSubmit() { + if (this.state.submitting) return; + const subject = (this.state.subject || "").trim(); + if (!subject) { + this.state.error = _t("Subject is required."); + return; + } + this.state.error = ""; + this.state.success = false; + this.state.submitting = true; + try { + const payload = { + kind: this.state.kind, + subject, + description: this.state.description || "", + error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "", + attachments: this.state.attachments.map((a) => ({ + name: a.name, + mimetype: a.mimetype, + data_b64: a.data_b64, + })), + page_url: window.location.href, + user_agent: navigator.userAgent, + }; + const res = await rpc("/fusion_helpdesk/submit", payload); + if (!res.ok) { + this.state.error = res.message || _t("Submission failed."); + } else { + this.state.success = true; + this.state.ticketId = res.ticket_id; + this.state.ticketUrl = res.ticket_url; + this.state.attached = res.attached || 0; + // Reset the editable fields so user can file another if they want. + this.state.subject = ""; + this.state.description = ""; + this.state.errorCode = ""; + this.state.attachments = []; + } + } catch (err) { + this.state.error = (err && err.message) || _t("Network error."); + } finally { + this.state.submitting = false; + } + } +} diff --git a/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js b/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js new file mode 100644 index 00000000..16761db7 --- /dev/null +++ b/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js @@ -0,0 +1,33 @@ +/** @odoo-module **/ +// Fusion Helpdesk — top systray icon. Sequence chosen so the icon +// appears to the LEFT of the attendance check-in button. Odoo +// systray ordering is by sequence ascending (lower = leftmost in the +// systray bar). hr_attendance ships at sequence 100, so we use 99. + +import { Component } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog"; + +class FusionHelpdeskSystray extends Component { + static template = "fusion_helpdesk.SystrayItem"; + static props = {}; + + setup() { + this.dialog = useService("dialog"); + } + + onClick() { + this.dialog.add(FusionHelpdeskDialog, {}); + } +} + +export const fusionHelpdeskSystrayItem = { + Component: FusionHelpdeskSystray, +}; + +registry + .category("systray") + .add("fusion_helpdesk.report_button", fusionHelpdeskSystrayItem, { + sequence: 99, + }); diff --git a/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss b/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss new file mode 100644 index 00000000..0fa58361 --- /dev/null +++ b/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss @@ -0,0 +1,172 @@ +// Fusion Helpdesk Reporter — systray button + dialog styling. +// Dark-mode aware via Odoo's $o-webclient-color-scheme branch. + +$o-webclient-color-scheme: bright !default; + +$_fhd-text-hex: #21252b; +$_fhd-muted-hex: #6c757d; +$_fhd-bg-hex: #ffffff; +$_fhd-border-hex: #d8dadd; +$_fhd-hover-hex: #f3f4f6; +$_fhd-accent-hex: #2c89e9; + +@if $o-webclient-color-scheme == dark { + $_fhd-text-hex: #e6e9ef !global; + $_fhd-muted-hex: #9aa3ad !global; + $_fhd-bg-hex: #22262d !global; + $_fhd-border-hex: #3a3f47 !global; + $_fhd-hover-hex: #2c313a !global; + $_fhd-accent-hex: #4ea3ff !global; +} + +$fhd-text: var(--fhd-text, $_fhd-text-hex); +$fhd-muted: var(--fhd-muted, $_fhd-muted-hex); +$fhd-bg: var(--fhd-bg, $_fhd-bg-hex); +$fhd-border: var(--fhd-border, $_fhd-border-hex); +$fhd-hover: var(--fhd-hover, $_fhd-hover-hex); +$fhd-accent: var(--fhd-accent, $_fhd-accent-hex); + +// Systray icon +.o_fhd_systray { + .o_fhd_systray_btn { + background: transparent; + border: none; + padding: 0 0.5rem; + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + + &:hover .o_fhd_systray_img { + transform: scale(1.08); + filter: drop-shadow(0 0 2px rgba(78, 163, 255, 0.45)); + } + } + + .o_fhd_systray_img { + height: 22px; + width: 22px; + object-fit: contain; + transition: transform 120ms ease, filter 120ms ease; + } + + // Hide the dropdown caret Bootstrap injects. + .dropdown-toggle::after { display: none; } +} + +// Dialog +.o_fhd_dialog { + color: $fhd-text; + + .o_fhd_kind_row { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + } + + .o_fhd_kind_chip { + flex: 1; + padding: 0.6rem 0.75rem; + background-color: $fhd-bg; + border: 1px solid $fhd-border; + border-radius: 6px; + color: $fhd-text; + cursor: pointer; + font-weight: 500; + + &:hover { background-color: $fhd-hover; } + + &.o_fhd_kind_active { + border-color: $fhd-accent; + color: $fhd-accent; + background-color: rgba(44, 137, 233, 0.10); + } + } + + .o_fhd_field { + margin-bottom: 0.85rem; + + > label { + display: block; + font-weight: 500; + margin-bottom: 0.25rem; + color: $fhd-text; + } + } + + .o_fhd_hint { + font-weight: 400; + font-size: 0.8rem; + color: $fhd-muted; + margin-left: 0.4rem; + } + + .o_fhd_mono { + font-family: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace; + font-size: 0.85rem; + } + + .o_fhd_actions_row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + + .o_fhd_btn { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.8rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + border: 1px solid $fhd-border; + background-color: $fhd-bg; + color: $fhd-text; + + &:hover:not(:disabled) { background-color: $fhd-hover; } + + &:disabled { opacity: 0.6; cursor: default; } + } + + .o_fhd_attach_list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 0.5rem; + } + + .o_fhd_attach_item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.6rem; + background-color: $fhd-hover; + border: 1px solid $fhd-border; + border-radius: 4px; + font-size: 0.85rem; + } + + .o_fhd_attach_name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .o_fhd_attach_size { + color: $fhd-muted; + font-variant-numeric: tabular-nums; + } + + .o_fhd_attach_remove { + background: transparent; + border: none; + color: $fhd-muted; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + padding: 0 0.25rem; + + &:hover { color: #d32f2f; } + } +} diff --git a/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml b/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml new file mode 100644 index 00000000..f53497f8 --- /dev/null +++ b/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml @@ -0,0 +1,110 @@ + + + + + +
+ +
+ + +
+ + +
+ + +
+ + +
+