/** @odoo-module **/ // 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, markup, 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"; // Wrap message bodies in `markup()` so the OWL template's `t-out` renders them // as HTML instead of escaping `
…
` literally. Bodies come from central as // plain JSON strings (Markup doesn't survive JSON-RPC) but are already sanitised // server-side by Odoo's mail.thread, so we trust them. Mutates the list in place // because that's what the existing call sites assign straight to state. function _markupBodies(messages) { for (const m of messages || []) { m.body = markup(m.body || ""); } return messages || []; } 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, initialTab: { type: String, optional: true }, }; setup() { this.notification = useService("notification"); this.state = useState({ tab: this.props.initialTab || "new", // 'new' | 'list' | 'thread' // ---- New report ---- kind: "bug", subject: "", description: "", errorCode: "", replyEmail: user.login || "", attachments: [], isCritical: false, capturing: false, submitting: false, error: "", success: false, ticketId: null, ticketUrl: "", attached: 0, failed: 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() { 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; } // ================================================================== // 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) { console.error("fusion_helpdesk: my_tickets failed", 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(); } // Bucket tickets into Critical / New & Open / Solved for the section view. // Backend already sorts by write_date desc, so iterating preserves latest- // first within each bucket. We only render non-empty sections so the // dialog doesn't show "0 Critical" noise when the user has none. get groupedTickets() { const groups = [ { key: "critical", title: _t("Critical"), iconClass: "fa fa-exclamation-circle", className: "o_fhd_group_critical", tickets: [] }, { key: "open", title: _t("New & Open"), iconClass: "fa fa-inbox", className: "o_fhd_group_open", tickets: [] }, { key: "solved", title: _t("Solved"), iconClass: "fa fa-check-circle", className: "o_fhd_group_solved", tickets: [] }, ]; const byKey = Object.fromEntries(groups.map((g) => [g.key, g])); for (const t of this.state.tickets) { (byKey[t.group] || byKey.open).tickets.push(t); } return groups.filter((g) => g.tickets.length); } // KPI stats — same source as the section list so counts always agree // with what the user sees scrolling below. "Open" rolls Critical + Open // groups together (anything not folded); "Closed" is the Solved bucket. get ticketStats() { const t = this.state.tickets; const critical = t.filter((x) => x.group === "critical").length; const open = t.filter((x) => x.group === "critical" || x.group === "open").length; const closed = t.filter((x) => x.group === "solved").length; return { total: t.length, open, closed, critical }; } // Colour pill class per ticket. Critical priority shows as a dedicated red // pill alongside the stage pill (rendered in the template), so this only // needs to map the stage name to a hue. Match on substring (lowercased) so // a renamed "Resolved" / "Done" stage on central still maps to green. pillClass(t) { const s = (t.stage || "").toLowerCase(); if (s.includes("cancel")) return "o_fhd_pill o_fhd_pill_cancelled"; if (s.includes("solv") || s.includes("done") || s.includes("resolv")) { return "o_fhd_pill o_fhd_pill_solved"; } if (s === "new") return "o_fhd_pill o_fhd_pill_new"; if (s.includes("progress") || s.includes("test") || s.includes("hold")) { return "o_fhd_pill o_fhd_pill_progress"; } return "o_fhd_pill"; // unknown stage falls back to neutral chip } isCriticalRow(t) { return t.priority === "2" || t.priority === "3"; } // ================================================================== // 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; } _markupBodies(res.ticket.messages); 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) { console.error("fusion_helpdesk: open ticket failed", 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 = _markupBodies(res.messages) || this.state.current.messages; this.state.replyBody = ""; this.notification.add(_t("Reply sent."), { type: "success" }); } } catch (err) { console.error("fusion_helpdesk: send reply failed", 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) { 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" } ); } } 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); }); } 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) { 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(); 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"; } // ================================================================== // New report — 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 || "" : "", reply_email: (this.state.replyEmail || "").trim(), is_critical: !!this.state.isCritical, 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; this.state.failed = res.failed || 0; this.state.subject = ""; this.state.description = ""; this.state.errorCode = ""; this.state.attachments = []; this.state.isCritical = false; } } catch (err) { console.error("fusion_helpdesk: submit failed", err); this.state.error = (err && err.message) || _t("Network error."); } finally { this.state.submitting = false; } } }