From b6e7ad3485003b177f7236cdc3448a20df814962 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 03:32:48 -0400 Subject: [PATCH] feat(fusion_helpdesk): embedded ticket inbox UI + unread badge Dialog gains New/My Tickets tabs, ticket list (with admin Mine/All toggle + unread dots), thread view with inline reply, confirmed reply-email field, and an Open-full-ticket magic link. Systray shows an unread-reply badge (polled). Controller returns portal_url + per-message attachment counts. Assets compile clean (light+dark). Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_helpdesk/controllers/main.py | 14 +- .../static/src/js/fusion_helpdesk_dialog.js | 170 +++++++++-- .../static/src/js/fusion_helpdesk_systray.js | 40 ++- .../static/src/scss/fusion_helpdesk.scss | 167 ++++++++++ .../static/src/xml/fusion_helpdesk_dialog.xml | 285 ++++++++++++------ .../src/xml/fusion_helpdesk_systray.xml | 5 +- 6 files changed, 566 insertions(+), 115 deletions(-) diff --git a/fusion_helpdesk/controllers/main.py b/fusion_helpdesk/controllers/main.py index 8b124d83..a515f546 100644 --- a/fusion_helpdesk/controllers/main.py +++ b/fusion_helpdesk/controllers/main.py @@ -442,7 +442,8 @@ class FusionHelpdeskController(http.Controller): [[('model', '=', 'helpdesk.ticket'), ('res_id', '=', ticket_id), ('message_type', 'in', ['comment', 'email'])]], - {'fields': ['id', 'date', 'body', 'author_id', 'subtype_id'], + {'fields': ['id', 'date', 'body', 'author_id', 'subtype_id', + 'attachment_ids'], 'order': 'id asc'}, ) internal = self._internal_subtype_map( @@ -456,6 +457,7 @@ class FusionHelpdeskController(http.Controller): 'body': m['body'] or '', 'author': (m['author_id'][1] if m['author_id'] else ''), 'author_id': (m['author_id'][0] if m['author_id'] else False), + 'attachment_count': len(m.get('attachment_ids') or []), 'subtype_is_internal': internal.get(st[0], False) if st else False, } if is_public_message(msg): @@ -549,7 +551,8 @@ class FusionHelpdeskController(http.Controller): ) + [('id', '=', ticket_id)] try: found = self._rpc(cfg, 'helpdesk.ticket', 'search_read', [domain], - {'fields': ['id', 'name', 'stage_id'], 'limit': 1}) + {'fields': ['id', 'name', 'stage_id', + 'access_token'], 'limit': 1}) if not found: return {'ok': False, 'error': 'not_found', 'message': _('Ticket not found or not accessible.')} @@ -560,9 +563,16 @@ class FusionHelpdeskController(http.Controller): request.env['fusion.helpdesk.ticket.seen']._mark_seen( ticket_id, max(m['id'] for m in messages)) t = found[0] + # Magic link: the customer's own access-token URL on central, so they + # can open the full ticket (incl. attachments) in the portal if needed. + portal_url = '' + if t.get('access_token'): + portal_url = '%s/my/ticket/%s/%s' % ( + cfg['url'].rstrip('/'), t['id'], t['access_token']) return {'ok': True, 'ticket': { 'id': t['id'], 'subject': t['name'], 'stage': t['stage_id'][1] if t['stage_id'] else '', + 'portal_url': portal_url, 'messages': messages}} @http.route('/fusion_helpdesk/ticket//reply', diff --git a/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js b/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js index 486e5351..b4a89ad2 100644 --- a/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js +++ b/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js @@ -1,13 +1,20 @@ /** @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. +// Fusion Helpdesk — submission + follow-up dialog. +// +// Two tabs: +// • New — report a bug / request a feature (the original form), +// plus a confirmed "Your email" field so support can reply. +// • My Tickets — a live RPC view of the user's tickets on the central +// Odoo: list → open one → read support's replies → reply +// inline, without ever leaving this Odoo or logging in. +// +// Tickets are NOT copied locally — every list/thread/reply is a live call +// to the central Helpdesk, scoped server-side to the logged-in user. -import { Component, useState } from "@odoo/owl"; +import { Component, useState, onWillStart } from "@odoo/owl"; import { Dialog } from "@web/core/dialog/dialog"; import { rpc } from "@web/core/network/rpc"; +import { user } from "@web/core/user"; import { useService } from "@web/core/utils/hooks"; import { _t } from "@web/core/l10n/translation"; @@ -18,16 +25,20 @@ export class FusionHelpdeskDialog extends Component { static components = { Dialog }; static props = { close: Function, + initialTab: { type: String, optional: true }, }; setup() { this.notification = useService("notification"); this.state = useState({ - kind: "bug", // 'bug' | 'feature' + tab: this.props.initialTab || "new", // 'new' | 'list' | 'thread' + // ---- New report ---- + kind: "bug", subject: "", description: "", errorCode: "", - attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}] + replyEmail: user.login || "", + attachments: [], capturing: false, submitting: false, error: "", @@ -35,21 +46,142 @@ export class FusionHelpdeskDialog extends Component { ticketId: null, ticketUrl: "", attached: 0, + // ---- My Tickets ---- + isAdmin: false, + scope: "mine", // 'mine' | 'all' + tickets: [], + loadingList: false, + listError: "", + // ---- Thread ---- + current: null, // {id, subject, stage, portal_url, messages} + loadingThread: false, + threadError: "", + replyBody: "", + sendingReply: false, + }); + + onWillStart(async () => { + if (this.state.tab === "list") { + await this.loadList(); + } }); } get dialogTitle() { - return this.state.kind === "bug" - ? _t("Report a Bug") - : _t("Request a Feature"); + if (this.state.tab === "thread" && this.state.current) { + return this.state.current.subject; + } + if (this.state.tab === "list") { + return _t("My Tickets"); + } + return this.state.kind === "bug" ? _t("Report a Bug") : _t("Request a Feature"); + } + + // ------------------------------------------------------------------ + // Tabs + async setTab(tab) { + this.state.tab = tab; + this.state.error = ""; + if (tab === "list") { + await this.loadList(); + } } setKind(kind) { this.state.kind = kind; } - // ------------------------------------------------------------------ - // File input → b64 + // ================================================================== + // My Tickets — list + // ================================================================== + async loadList() { + if (this.state.loadingList) return; + this.state.loadingList = true; + this.state.listError = ""; + try { + const res = await rpc("/fusion_helpdesk/my_tickets", { + scope: this.state.scope, + }); + if (!res.ok) { + this.state.listError = res.message || _t("Could not load your tickets."); + this.state.tickets = []; + } else { + this.state.tickets = res.tickets || []; + this.state.isAdmin = !!res.is_admin; + } + } catch (err) { + this.state.listError = (err && err.message) || _t("Network error."); + } finally { + this.state.loadingList = false; + } + } + + async setScope(scope) { + if (this.state.scope === scope) return; + this.state.scope = scope; + await this.loadList(); + } + + // ================================================================== + // My Tickets — thread + // ================================================================== + async openTicket(ticketId) { + this.state.loadingThread = true; + this.state.threadError = ""; + this.state.replyBody = ""; + try { + const res = await rpc(`/fusion_helpdesk/ticket/${ticketId}`, {}); + if (!res.ok) { + this.state.threadError = res.message || _t("Could not open this ticket."); + return; + } + this.state.current = res.ticket; + this.state.tab = "thread"; + // The ticket is now seen server-side; clear its unread flag locally. + const row = this.state.tickets.find((t) => t.id === ticketId); + if (row) { + row.has_unread = false; + } + } catch (err) { + this.state.threadError = (err && err.message) || _t("Network error."); + } finally { + this.state.loadingThread = false; + } + } + + async backToList() { + this.state.current = null; + this.state.tab = "list"; + await this.loadList(); // refresh stages / unread after viewing + } + + async sendReply() { + const body = (this.state.replyBody || "").trim(); + if (!body || this.state.sendingReply || !this.state.current) return; + this.state.sendingReply = true; + this.state.threadError = ""; + try { + const res = await rpc( + `/fusion_helpdesk/ticket/${this.state.current.id}/reply`, + { body } + ); + if (!res.ok) { + this.state.threadError = res.message || _t("Could not send your reply."); + } else { + this.state.current.messages = res.messages || this.state.current.messages; + this.state.replyBody = ""; + this.notification.add(_t("Reply sent."), { type: "success" }); + } + } catch (err) { + this.state.threadError = (err && err.message) || _t("Network error."); + } finally { + this.state.sendingReply = false; + } + } + + // ================================================================== + // New report — files / screenshot (unchanged behaviour) + // ================================================================== async onFilesPicked(ev) { const files = Array.from(ev.target.files || []); for (const f of files) { @@ -75,7 +207,6 @@ export class FusionHelpdeskDialog extends Component { ); } } - // Reset the input so picking the same file again re-fires onchange. ev.target.value = ""; } @@ -92,8 +223,6 @@ export class FusionHelpdeskDialog extends Component { }); } - // ------------------------------------------------------------------ - // Screenshot capture via getDisplayMedia async onTakeScreenshot() { if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { this.notification.add( @@ -119,7 +248,6 @@ export class FusionHelpdeskDialog extends Component { 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), @@ -138,7 +266,6 @@ export class FusionHelpdeskDialog extends Component { 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; @@ -195,8 +322,9 @@ export class FusionHelpdeskDialog extends Component { return "fa fa-file-o"; } - // ------------------------------------------------------------------ - // Submit + // ================================================================== + // New report — submit + // ================================================================== async onSubmit() { if (this.state.submitting) return; const subject = (this.state.subject || "").trim(); @@ -213,6 +341,7 @@ export class FusionHelpdeskDialog extends Component { subject, description: this.state.description || "", error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "", + reply_email: (this.state.replyEmail || "").trim(), attachments: this.state.attachments.map((a) => ({ name: a.name, mimetype: a.mimetype, @@ -229,7 +358,6 @@ export class FusionHelpdeskDialog extends Component { 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 = ""; diff --git a/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js b/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js index 16761db7..15ca7ccd 100644 --- a/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js +++ b/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js @@ -1,24 +1,52 @@ /** @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. +// Fusion Helpdesk — top systray icon with an unread-reply badge. +// Sequence 99 places it just left of the attendance check-in button. -import { Component } from "@odoo/owl"; +import { Component, useState, onWillStart, onWillUnmount } from "@odoo/owl"; import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; import { useService } from "@web/core/utils/hooks"; import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog"; +const POLL_MS = 120000; // refresh the unread badge every 2 minutes + class FusionHelpdeskSystray extends Component { static template = "fusion_helpdesk.SystrayItem"; static props = {}; setup() { this.dialog = useService("dialog"); + this.state = useState({ unread: 0 }); + + onWillStart(async () => { + await this._refreshUnread(); + }); + + // Poll so a reply that lands while the user is working still + // surfaces without a page reload. Errors are swallowed server-side + // (the endpoint always returns a count) so the badge never breaks. + this._timer = setInterval(() => this._refreshUnread(), POLL_MS); + onWillUnmount(() => clearInterval(this._timer)); + } + + async _refreshUnread() { + try { + const res = await rpc("/fusion_helpdesk/unread_count", {}); + this.state.unread = (res && res.count) || 0; + } catch { + // Network/config hiccup — leave the badge as-is, don't throw. + } } onClick() { - this.dialog.add(FusionHelpdeskDialog, {}); + // If there are unread replies, drop straight into the inbox; + // otherwise open the New report form (the primary action). + const initialTab = this.state.unread > 0 ? "list" : "new"; + this.dialog.add( + FusionHelpdeskDialog, + { initialTab }, + { onClose: () => this._refreshUnread() } + ); } } diff --git a/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss b/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss index 0fa58361..45f1036d 100644 --- a/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss +++ b/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss @@ -170,3 +170,170 @@ $fhd-accent: var(--fhd-accent, $_fhd-accent-hex); &:hover { color: #d32f2f; } } } + +// Systray unread badge +.o_fhd_systray { + .o_fhd_systray_btn { position: relative; } + + .o_fhd_systray_badge { + position: absolute; + top: -2px; + right: 0; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background-color: #d9534f; + color: #fff; + font-size: 0.65rem; + font-weight: 700; + line-height: 16px; + text-align: center; + } +} + +// Inbox additions (tabs, list, thread) — share the dialog tokens above. +.o_fhd_dialog { + .o_fhd_muted { color: $fhd-muted; } + + .o_fhd_tabs { + display: flex; + gap: 0.25rem; + border-bottom: 1px solid $fhd-border; + margin-bottom: 1rem; + } + + .o_fhd_tab { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + padding: 0.5rem 0.9rem; + color: $fhd-muted; + cursor: pointer; + font-weight: 500; + + &:hover { color: $fhd-text; } + + &.o_fhd_tab_active { + color: $fhd-accent; + border-bottom-color: $fhd-accent; + } + } + + .o_fhd_scope_row { + display: flex; + gap: 0.5rem; + margin-bottom: 0.85rem; + } + + // Ticket list + .o_fhd_ticket_list { + display: flex; + flex-direction: column; + border: 1px solid $fhd-border; + border-radius: 6px; + overflow: hidden; + } + + .o_fhd_ticket_row { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + background-color: $fhd-bg; + border-bottom: 1px solid $fhd-border; + cursor: pointer; + + &:last-child { border-bottom: none; } + &:hover { background-color: $fhd-hover; } + } + + .o_fhd_unread_dot { + width: 9px; + height: 9px; + border-radius: 50%; + background-color: $fhd-accent; + flex: 0 0 auto; + } + + .o_fhd_unread_spacer { width: 9px; flex: 0 0 auto; } + + .o_fhd_ticket_ref { + color: $fhd-muted; + font-variant-numeric: tabular-nums; + flex: 0 0 auto; + } + + .o_fhd_ticket_subject { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .o_fhd_ticket_stage { + flex: 0 0 auto; + font-size: 0.78rem; + padding: 0.1rem 0.5rem; + border-radius: 10px; + background-color: $fhd-hover; + border: 1px solid $fhd-border; + color: $fhd-muted; + } + + // Thread + .o_fhd_thread_head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.6rem; + } + + .o_fhd_open_portal { + font-size: 0.85rem; + color: $fhd-accent; + text-decoration: none; + &:hover { text-decoration: underline; } + } + + .o_fhd_thread { + display: flex; + flex-direction: column; + gap: 0.6rem; + max-height: 45vh; + overflow-y: auto; + padding: 0.25rem; + } + + .o_fhd_msg { + border: 1px solid $fhd-border; + border-radius: 6px; + padding: 0.6rem 0.75rem; + background-color: $fhd-bg; + } + + .o_fhd_msg_head { + display: flex; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.3rem; + font-size: 0.82rem; + } + + .o_fhd_msg_author { font-weight: 600; color: $fhd-text; } + .o_fhd_msg_date { color: $fhd-muted; font-variant-numeric: tabular-nums; } + + .o_fhd_msg_body { + color: $fhd-text; + font-size: 0.9rem; + word-break: break-word; + + p:last-child { margin-bottom: 0; } + } + + .o_fhd_msg_attach { + margin-top: 0.4rem; + font-size: 0.8rem; + color: $fhd-muted; + } +} diff --git a/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml b/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml index f53497f8..6aa83afb 100644 --- a/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml +++ b/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml @@ -4,105 +4,220 @@
- -
- -
- -
- - -
- - -
-