feat(fusion_helpdesk): customer follow-up + embedded ticket inbox
Squash-merge of feat/helpdesk-customer-followup. The billing and fusion_login_audit work from that branch is already on main (landed separately); this lands only the helpdesk feature. - Identity keystone: submit() forwards partner_email/partner_name/ x_fc_client_label so the central Helpdesk find-or-creates the customer partner and subscribes them as a follower (enables reply emails + magic link). - Embedded in-app 'My Tickets' inbox: server-side scoped read/reply RPC endpoints, per-user seen tracking (fusion.helpdesk.ticket.seen), systray unread badge. Defense-in-depth scope domain + _norm_email normalisation (wildcard emails cannot widen scope). - fusion_helpdesk_central: x_fc_client_label field + list/search views + branded acknowledgement email template. - Deployed and smoke-tested live: nexa central 19.0.1.1.0, entech client 19.0.1.4.1 (requires Contact Creation on the central service account). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,146 @@ export class FusionHelpdeskDialog extends Component {
|
||||
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() {
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 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) {
|
||||
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 = 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) {
|
||||
@@ -75,7 +211,6 @@ export class FusionHelpdeskDialog extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Reset the input so picking the same file again re-fires onchange.
|
||||
ev.target.value = "";
|
||||
}
|
||||
|
||||
@@ -92,8 +227,6 @@ export class FusionHelpdeskDialog extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Screenshot capture via getDisplayMedia
|
||||
async onTakeScreenshot() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
||||
this.notification.add(
|
||||
@@ -119,7 +252,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 +270,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 +326,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 +345,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,13 +362,14 @@ 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.failed = res.failed || 0;
|
||||
this.state.subject = "";
|
||||
this.state.description = "";
|
||||
this.state.errorCode = "";
|
||||
this.state.attachments = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("fusion_helpdesk: submit failed", err);
|
||||
this.state.error = (err && err.message) || _t("Network error.");
|
||||
} finally {
|
||||
this.state.submitting = false;
|
||||
|
||||
@@ -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() }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,105 +4,225 @@
|
||||
<t t-name="fusion_helpdesk.Dialog">
|
||||
<Dialog title="dialogTitle" size="'lg'">
|
||||
<div class="o_fhd_dialog">
|
||||
<!-- Kind selector -->
|
||||
<div class="o_fhd_kind_row">
|
||||
<button type="button"
|
||||
class="o_fhd_kind_chip"
|
||||
t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }"
|
||||
t-on-click="() => this.setKind('bug')">
|
||||
<i class="fa fa-bug me-1"/> Report a Bug
|
||||
|
||||
<!-- ===== Tabs ===== -->
|
||||
<div class="o_fhd_tabs">
|
||||
<button type="button" class="o_fhd_tab"
|
||||
t-att-class="{ 'o_fhd_tab_active': state.tab === 'new' }"
|
||||
t-on-click="() => this.setTab('new')">
|
||||
<i class="fa fa-plus-circle me-1"/> New
|
||||
</button>
|
||||
<button type="button"
|
||||
class="o_fhd_kind_chip"
|
||||
t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }"
|
||||
t-on-click="() => this.setKind('feature')">
|
||||
<i class="fa fa-lightbulb-o me-1"/> Request a Feature
|
||||
<button type="button" class="o_fhd_tab"
|
||||
t-att-class="{ 'o_fhd_tab_active': state.tab === 'list' || state.tab === 'thread' }"
|
||||
t-on-click="() => this.setTab('list')">
|
||||
<i class="fa fa-ticket me-1"/> My Tickets
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Subject -->
|
||||
<div class="o_fhd_field">
|
||||
<label>Subject *</label>
|
||||
<input type="text" class="form-control"
|
||||
t-att-value="state.subject"
|
||||
t-on-input="(ev) => state.subject = ev.target.value"
|
||||
t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="o_fhd_field">
|
||||
<label t-esc="state.kind === 'bug' ? 'What were you doing? What did you expect?' : 'Describe the desired behaviour and the use case'"/>
|
||||
<textarea class="form-control" rows="5"
|
||||
t-att-value="state.description"
|
||||
t-on-input="(ev) => state.description = ev.target.value"
|
||||
placeholder="Steps to reproduce, expected vs. actual, business impact…"/>
|
||||
</div>
|
||||
|
||||
<!-- Error code (bug only) -->
|
||||
<div class="o_fhd_field" t-if="state.kind === 'bug'">
|
||||
<label>
|
||||
Error code / traceback
|
||||
<span class="o_fhd_hint">paste any error message or stack trace</span>
|
||||
</label>
|
||||
<textarea class="form-control o_fhd_mono" rows="3"
|
||||
t-att-value="state.errorCode"
|
||||
t-on-input="(ev) => state.errorCode = ev.target.value"
|
||||
placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div class="o_fhd_field">
|
||||
<label>Attachments</label>
|
||||
<div class="o_fhd_actions_row">
|
||||
<label class="o_fhd_btn o_fhd_btn_secondary">
|
||||
<i class="fa fa-paperclip me-1"/> Attach files
|
||||
<input type="file" multiple="multiple" class="d-none"
|
||||
t-on-change="onFilesPicked"/>
|
||||
</label>
|
||||
<button type="button" class="o_fhd_btn o_fhd_btn_secondary"
|
||||
t-on-click="onTakeScreenshot"
|
||||
t-att-disabled="state.capturing">
|
||||
<i class="fa fa-camera me-1"/>
|
||||
<t t-if="state.capturing">Capturing…</t>
|
||||
<t t-else="">Capture screenshot</t>
|
||||
<!-- ===== NEW report ===== -->
|
||||
<div t-if="state.tab === 'new'">
|
||||
<div class="o_fhd_kind_row">
|
||||
<button type="button" class="o_fhd_kind_chip"
|
||||
t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }"
|
||||
t-on-click="() => this.setKind('bug')">
|
||||
<i class="fa fa-bug me-1"/> Report a Bug
|
||||
</button>
|
||||
<button type="button" class="o_fhd_kind_chip"
|
||||
t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }"
|
||||
t-on-click="() => this.setKind('feature')">
|
||||
<i class="fa fa-lightbulb-o me-1"/> Request a Feature
|
||||
</button>
|
||||
</div>
|
||||
<div t-if="state.attachments.length" class="o_fhd_attach_list">
|
||||
<div t-foreach="state.attachments" t-as="att" t-key="att_index"
|
||||
class="o_fhd_attach_item">
|
||||
<i t-att-class="att.iconClass"/>
|
||||
<span class="o_fhd_attach_name" t-esc="att.name"/>
|
||||
<span class="o_fhd_attach_size" t-esc="att.sizeLabel"/>
|
||||
<button type="button" class="o_fhd_attach_remove"
|
||||
t-on-click="() => this.removeAttachment(att_index)">×</button>
|
||||
|
||||
<div class="o_fhd_field">
|
||||
<label>Subject *</label>
|
||||
<input type="text" class="form-control"
|
||||
t-att-value="state.subject"
|
||||
t-on-input="(ev) => state.subject = ev.target.value"
|
||||
t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fhd_field">
|
||||
<label>
|
||||
Your email
|
||||
<span class="o_fhd_hint">we'll reply here — edit if you'd like replies elsewhere</span>
|
||||
</label>
|
||||
<input type="email" class="form-control"
|
||||
t-att-value="state.replyEmail"
|
||||
t-on-input="(ev) => state.replyEmail = ev.target.value"
|
||||
placeholder="you@example.com"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fhd_field">
|
||||
<label t-esc="state.kind === 'bug' ? 'What were you doing? What did you expect?' : 'Describe the desired behaviour and the use case'"/>
|
||||
<textarea class="form-control" rows="5"
|
||||
t-att-value="state.description"
|
||||
t-on-input="(ev) => state.description = ev.target.value"
|
||||
placeholder="Steps to reproduce, expected vs. actual, business impact…"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fhd_field" t-if="state.kind === 'bug'">
|
||||
<label>
|
||||
Error code / traceback
|
||||
<span class="o_fhd_hint">paste any error message or stack trace</span>
|
||||
</label>
|
||||
<textarea class="form-control o_fhd_mono" rows="3"
|
||||
t-att-value="state.errorCode"
|
||||
t-on-input="(ev) => state.errorCode = ev.target.value"
|
||||
placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fhd_field">
|
||||
<label>Attachments</label>
|
||||
<div class="o_fhd_actions_row">
|
||||
<label class="o_fhd_btn o_fhd_btn_secondary">
|
||||
<i class="fa fa-paperclip me-1"/> Attach files
|
||||
<input type="file" multiple="multiple" class="d-none"
|
||||
t-on-change="onFilesPicked"/>
|
||||
</label>
|
||||
<button type="button" class="o_fhd_btn o_fhd_btn_secondary"
|
||||
t-on-click="onTakeScreenshot"
|
||||
t-att-disabled="state.capturing">
|
||||
<i class="fa fa-camera me-1"/>
|
||||
<t t-if="state.capturing">Capturing…</t>
|
||||
<t t-else="">Capture screenshot</t>
|
||||
</button>
|
||||
</div>
|
||||
<div t-if="state.attachments.length" class="o_fhd_attach_list">
|
||||
<div t-foreach="state.attachments" t-as="att" t-key="att_index"
|
||||
class="o_fhd_attach_item">
|
||||
<i t-att-class="att.iconClass"/>
|
||||
<span class="o_fhd_attach_name" t-esc="att.name"/>
|
||||
<span class="o_fhd_attach_size" t-esc="att.sizeLabel"/>
|
||||
<button type="button" class="o_fhd_attach_remove"
|
||||
t-on-click="() => this.removeAttachment(att_index)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="state.error" class="alert alert-danger mt-2">
|
||||
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/>
|
||||
</div>
|
||||
<div t-if="state.success" class="alert alert-success mt-2">
|
||||
<i class="fa fa-check-circle me-1"/>
|
||||
Thanks — ticket
|
||||
<a t-att-href="state.ticketUrl" target="_blank">#<t t-esc="state.ticketId"/></a>
|
||||
created<t t-if="state.attached"> with <t t-esc="state.attached"/> attachment(s)</t>.
|
||||
You'll get replies by email, and can follow up under <b>My Tickets</b>.
|
||||
</div>
|
||||
<div t-if="state.success and state.failed" class="alert alert-warning mt-2">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<t t-esc="state.failed"/> attachment(s) could not be uploaded.
|
||||
Open the ticket from <b>My Tickets</b> and add them there.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== LIST ===== -->
|
||||
<div t-if="state.tab === 'list'">
|
||||
<div t-if="state.isAdmin" class="o_fhd_scope_row">
|
||||
<button type="button" class="o_fhd_kind_chip"
|
||||
t-att-class="{ 'o_fhd_kind_active': state.scope === 'mine' }"
|
||||
t-on-click="() => this.setScope('mine')">Mine</button>
|
||||
<button type="button" class="o_fhd_kind_chip"
|
||||
t-att-class="{ 'o_fhd_kind_active': state.scope === 'all' }"
|
||||
t-on-click="() => this.setScope('all')">All (deployment)</button>
|
||||
</div>
|
||||
|
||||
<div t-if="state.loadingList" class="o_fhd_muted text-center p-3">
|
||||
<i class="fa fa-spinner fa-spin me-1"/> Loading your tickets…
|
||||
</div>
|
||||
<div t-elif="state.listError" class="alert alert-danger">
|
||||
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.listError"/>
|
||||
</div>
|
||||
<div t-elif="!state.tickets.length" class="o_fhd_muted text-center p-4">
|
||||
<i class="fa fa-inbox fa-2x d-block mb-2"/>
|
||||
No tickets yet. Use the <b>New</b> tab to report a bug or request a feature.
|
||||
</div>
|
||||
<div t-else="" class="o_fhd_ticket_list">
|
||||
<div t-foreach="state.tickets" t-as="t" t-key="t.id"
|
||||
class="o_fhd_ticket_row" t-on-click="() => this.openTicket(t.id)">
|
||||
<span t-if="t.has_unread" class="o_fhd_unread_dot" title="New reply"/>
|
||||
<span t-else="" class="o_fhd_unread_spacer"/>
|
||||
<span class="o_fhd_ticket_ref" t-esc="'#' + t.ref"/>
|
||||
<span class="o_fhd_ticket_subject" t-esc="t.subject"/>
|
||||
<span class="o_fhd_ticket_stage" t-esc="t.stage"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result feedback -->
|
||||
<div t-if="state.error" class="alert alert-danger mt-2">
|
||||
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/>
|
||||
</div>
|
||||
<div t-if="state.success" class="alert alert-success mt-2">
|
||||
<i class="fa fa-check-circle me-1"/>
|
||||
Thanks — ticket
|
||||
<a t-att-href="state.ticketUrl" target="_blank">
|
||||
#<t t-esc="state.ticketId"/>
|
||||
</a> created<t t-if="state.attached"> with <t t-esc="state.attached"/> attachment(s)</t>.
|
||||
<!-- ===== THREAD ===== -->
|
||||
<div t-if="state.tab === 'thread'">
|
||||
<div t-if="state.loadingThread" class="o_fhd_muted text-center p-3">
|
||||
<i class="fa fa-spinner fa-spin me-1"/> Loading…
|
||||
</div>
|
||||
<t t-elif="state.current">
|
||||
<div class="o_fhd_thread_head">
|
||||
<span class="o_fhd_ticket_stage" t-esc="state.current.stage"/>
|
||||
<a t-if="state.current.portal_url" class="o_fhd_open_portal"
|
||||
t-att-href="state.current.portal_url" target="_blank">
|
||||
Open full ticket <i class="fa fa-external-link"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="o_fhd_thread">
|
||||
<div t-if="!state.current.messages.length" class="o_fhd_muted p-2">
|
||||
No messages yet.
|
||||
</div>
|
||||
<div t-foreach="state.current.messages" t-as="m" t-key="m.id"
|
||||
class="o_fhd_msg">
|
||||
<div class="o_fhd_msg_head">
|
||||
<span class="o_fhd_msg_author" t-esc="m.author"/>
|
||||
<span class="o_fhd_msg_date" t-esc="m.date"/>
|
||||
</div>
|
||||
<div class="o_fhd_msg_body" t-out="m.body"/>
|
||||
<div t-if="m.attachment_count" class="o_fhd_msg_attach">
|
||||
<i class="fa fa-paperclip me-1"/>
|
||||
<t t-esc="m.attachment_count"/> attachment(s) —
|
||||
open the full ticket to download.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="state.threadError" class="alert alert-danger mt-2">
|
||||
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.threadError"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fhd_field mt-2">
|
||||
<label>Your reply</label>
|
||||
<textarea class="form-control" rows="3"
|
||||
t-att-value="state.replyBody"
|
||||
t-on-input="(ev) => state.replyBody = ev.target.value"
|
||||
placeholder="Add a follow-up… support will be notified."/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Footer ===== -->
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="onSubmit"
|
||||
t-att-disabled="state.submitting or !state.subject.trim()">
|
||||
<t t-if="state.submitting"><i class="fa fa-spinner fa-spin me-1"/></t>
|
||||
<t t-else=""><i class="fa fa-paper-plane me-1"/></t>
|
||||
Submit
|
||||
</button>
|
||||
<button class="btn btn-secondary" t-on-click="props.close">
|
||||
Close
|
||||
</button>
|
||||
<t t-if="state.tab === 'new'">
|
||||
<button class="btn btn-primary" t-on-click="onSubmit"
|
||||
t-att-disabled="state.submitting or !state.subject.trim()">
|
||||
<t t-if="state.submitting"><i class="fa fa-spinner fa-spin me-1"/></t>
|
||||
<t t-else=""><i class="fa fa-paper-plane me-1"/></t>
|
||||
Submit
|
||||
</button>
|
||||
<button class="btn btn-secondary" t-on-click="props.close">Close</button>
|
||||
</t>
|
||||
<t t-elif="state.tab === 'thread'">
|
||||
<button class="btn btn-primary" t-on-click="sendReply"
|
||||
t-att-disabled="state.sendingReply or !state.replyBody.trim()">
|
||||
<t t-if="state.sendingReply"><i class="fa fa-spinner fa-spin me-1"/></t>
|
||||
<t t-else=""><i class="fa fa-reply me-1"/></t>
|
||||
Send reply
|
||||
</button>
|
||||
<button class="btn btn-secondary" t-on-click="backToList">
|
||||
<i class="fa fa-arrow-left me-1"/> Back
|
||||
</button>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<button class="btn btn-secondary" t-on-click="props.close">Close</button>
|
||||
</t>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
<div class="o_fhd_systray dropdown">
|
||||
<button type="button"
|
||||
class="o_fhd_systray_btn dropdown-toggle"
|
||||
title="Report a bug or request a feature"
|
||||
title="Report a bug, request a feature, or follow up on your tickets"
|
||||
t-on-click="onClick">
|
||||
<img src="/fusion_helpdesk/static/description/help_icon.png"
|
||||
alt="Help"
|
||||
class="o_fhd_systray_img"/>
|
||||
<span t-if="state.unread > 0"
|
||||
class="o_fhd_systray_badge"
|
||||
t-esc="state.unread > 99 ? '99+' : state.unread"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
Reference in New Issue
Block a user