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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-27 03:32:48 -04:00
parent 304e19ce45
commit b6e7ad3485
6 changed files with 566 additions and 115 deletions

View File

@@ -442,7 +442,8 @@ class FusionHelpdeskController(http.Controller):
[[('model', '=', 'helpdesk.ticket'), [[('model', '=', 'helpdesk.ticket'),
('res_id', '=', ticket_id), ('res_id', '=', ticket_id),
('message_type', 'in', ['comment', 'email'])]], ('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'}, 'order': 'id asc'},
) )
internal = self._internal_subtype_map( internal = self._internal_subtype_map(
@@ -456,6 +457,7 @@ class FusionHelpdeskController(http.Controller):
'body': m['body'] or '', 'body': m['body'] or '',
'author': (m['author_id'][1] if m['author_id'] else ''), 'author': (m['author_id'][1] if m['author_id'] else ''),
'author_id': (m['author_id'][0] if m['author_id'] else False), '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, 'subtype_is_internal': internal.get(st[0], False) if st else False,
} }
if is_public_message(msg): if is_public_message(msg):
@@ -549,7 +551,8 @@ class FusionHelpdeskController(http.Controller):
) + [('id', '=', ticket_id)] ) + [('id', '=', ticket_id)]
try: try:
found = self._rpc(cfg, 'helpdesk.ticket', 'search_read', [domain], 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: if not found:
return {'ok': False, 'error': 'not_found', return {'ok': False, 'error': 'not_found',
'message': _('Ticket not found or not accessible.')} 'message': _('Ticket not found or not accessible.')}
@@ -560,9 +563,16 @@ class FusionHelpdeskController(http.Controller):
request.env['fusion.helpdesk.ticket.seen']._mark_seen( request.env['fusion.helpdesk.ticket.seen']._mark_seen(
ticket_id, max(m['id'] for m in messages)) ticket_id, max(m['id'] for m in messages))
t = found[0] 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': { return {'ok': True, 'ticket': {
'id': t['id'], 'subject': t['name'], 'id': t['id'], 'subject': t['name'],
'stage': t['stage_id'][1] if t['stage_id'] else '', 'stage': t['stage_id'][1] if t['stage_id'] else '',
'portal_url': portal_url,
'messages': messages}} 'messages': messages}}
@http.route('/fusion_helpdesk/ticket/<int:ticket_id>/reply', @http.route('/fusion_helpdesk/ticket/<int:ticket_id>/reply',

View File

@@ -1,13 +1,20 @@
/** @odoo-module **/ /** @odoo-module **/
// Fusion Helpdesk — submission dialog. Lets the user pick Bug or // Fusion Helpdesk — submission + follow-up dialog.
// Feature, fill in subject + description, paste an error code, attach //
// files, and capture a screenshot via the browser's getDisplayMedia // Two tabs:
// API. On submit, the payload is POSTed to /fusion_helpdesk/submit // • New — report a bug / request a feature (the original form),
// which forwards it (XML-RPC) to a central Odoo Helpdesk. // 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 { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc"; import { rpc } from "@web/core/network/rpc";
import { user } from "@web/core/user";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation"; import { _t } from "@web/core/l10n/translation";
@@ -18,16 +25,20 @@ export class FusionHelpdeskDialog extends Component {
static components = { Dialog }; static components = { Dialog };
static props = { static props = {
close: Function, close: Function,
initialTab: { type: String, optional: true },
}; };
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
this.state = useState({ this.state = useState({
kind: "bug", // 'bug' | 'feature' tab: this.props.initialTab || "new", // 'new' | 'list' | 'thread'
// ---- New report ----
kind: "bug",
subject: "", subject: "",
description: "", description: "",
errorCode: "", errorCode: "",
attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}] replyEmail: user.login || "",
attachments: [],
capturing: false, capturing: false,
submitting: false, submitting: false,
error: "", error: "",
@@ -35,21 +46,142 @@ export class FusionHelpdeskDialog extends Component {
ticketId: null, ticketId: null,
ticketUrl: "", ticketUrl: "",
attached: 0, 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() { get dialogTitle() {
return this.state.kind === "bug" if (this.state.tab === "thread" && this.state.current) {
? _t("Report a Bug") return this.state.current.subject;
: _t("Request a Feature"); }
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) { setKind(kind) {
this.state.kind = 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) { async onFilesPicked(ev) {
const files = Array.from(ev.target.files || []); const files = Array.from(ev.target.files || []);
for (const f of 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 = ""; ev.target.value = "";
} }
@@ -92,8 +223,6 @@ export class FusionHelpdeskDialog extends Component {
}); });
} }
// ------------------------------------------------------------------
// Screenshot capture via getDisplayMedia
async onTakeScreenshot() { async onTakeScreenshot() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
this.notification.add( this.notification.add(
@@ -119,7 +248,6 @@ export class FusionHelpdeskDialog extends Component {
rawSize: blob.size, rawSize: blob.size,
}); });
} catch (err) { } catch (err) {
// User cancelled the picker — silently swallow. Other errors → notify.
if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") { if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") {
this.notification.add( this.notification.add(
_t("Screenshot failed: %s").replace("%s", err.message || err), _t("Screenshot failed: %s").replace("%s", err.message || err),
@@ -138,7 +266,6 @@ export class FusionHelpdeskDialog extends Component {
const video = document.createElement("video"); const video = document.createElement("video");
video.srcObject = stream; video.srcObject = stream;
await video.play(); await video.play();
// Give the browser one frame to settle the picker chrome.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = video.videoWidth; canvas.width = video.videoWidth;
@@ -195,8 +322,9 @@ export class FusionHelpdeskDialog extends Component {
return "fa fa-file-o"; return "fa fa-file-o";
} }
// ------------------------------------------------------------------ // ==================================================================
// Submit // New report — submit
// ==================================================================
async onSubmit() { async onSubmit() {
if (this.state.submitting) return; if (this.state.submitting) return;
const subject = (this.state.subject || "").trim(); const subject = (this.state.subject || "").trim();
@@ -213,6 +341,7 @@ export class FusionHelpdeskDialog extends Component {
subject, subject,
description: this.state.description || "", description: this.state.description || "",
error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "", error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "",
reply_email: (this.state.replyEmail || "").trim(),
attachments: this.state.attachments.map((a) => ({ attachments: this.state.attachments.map((a) => ({
name: a.name, name: a.name,
mimetype: a.mimetype, mimetype: a.mimetype,
@@ -229,7 +358,6 @@ export class FusionHelpdeskDialog extends Component {
this.state.ticketId = res.ticket_id; this.state.ticketId = res.ticket_id;
this.state.ticketUrl = res.ticket_url; this.state.ticketUrl = res.ticket_url;
this.state.attached = res.attached || 0; this.state.attached = res.attached || 0;
// Reset the editable fields so user can file another if they want.
this.state.subject = ""; this.state.subject = "";
this.state.description = ""; this.state.description = "";
this.state.errorCode = ""; this.state.errorCode = "";

View File

@@ -1,24 +1,52 @@
/** @odoo-module **/ /** @odoo-module **/
// Fusion Helpdesk — top systray icon. Sequence chosen so the icon // Fusion Helpdesk — top systray icon with an unread-reply badge.
// appears to the LEFT of the attendance check-in button. Odoo // Sequence 99 places it just left of the attendance check-in button.
// 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 { Component, useState, onWillStart, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog"; import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog";
const POLL_MS = 120000; // refresh the unread badge every 2 minutes
class FusionHelpdeskSystray extends Component { class FusionHelpdeskSystray extends Component {
static template = "fusion_helpdesk.SystrayItem"; static template = "fusion_helpdesk.SystrayItem";
static props = {}; static props = {};
setup() { setup() {
this.dialog = useService("dialog"); 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() { 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() }
);
} }
} }

View File

@@ -170,3 +170,170 @@ $fhd-accent: var(--fhd-accent, $_fhd-accent-hex);
&:hover { color: #d32f2f; } &: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;
}
}

View File

@@ -4,105 +4,220 @@
<t t-name="fusion_helpdesk.Dialog"> <t t-name="fusion_helpdesk.Dialog">
<Dialog title="dialogTitle" size="'lg'"> <Dialog title="dialogTitle" size="'lg'">
<div class="o_fhd_dialog"> <div class="o_fhd_dialog">
<!-- Kind selector -->
<div class="o_fhd_kind_row"> <!-- ===== Tabs ===== -->
<button type="button" <div class="o_fhd_tabs">
class="o_fhd_kind_chip" <button type="button" class="o_fhd_tab"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }" t-att-class="{ 'o_fhd_tab_active': state.tab === 'new' }"
t-on-click="() => this.setKind('bug')"> t-on-click="() => this.setTab('new')">
<i class="fa fa-bug me-1"/> Report a Bug <i class="fa fa-plus-circle me-1"/> New
</button> </button>
<button type="button" <button type="button" class="o_fhd_tab"
class="o_fhd_kind_chip" t-att-class="{ 'o_fhd_tab_active': state.tab === 'list' || state.tab === 'thread' }"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }" t-on-click="() => this.setTab('list')">
t-on-click="() => this.setKind('feature')"> <i class="fa fa-ticket me-1"/> My Tickets
<i class="fa fa-lightbulb-o me-1"/> Request a Feature
</button> </button>
</div> </div>
<!-- Subject --> <!-- ===== NEW report ===== -->
<div class="o_fhd_field"> <div t-if="state.tab === 'new'">
<label>Subject *</label> <div class="o_fhd_kind_row">
<input type="text" class="form-control" <button type="button" class="o_fhd_kind_chip"
t-att-value="state.subject" t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }"
t-on-input="(ev) => state.subject = ev.target.value" t-on-click="() => this.setKind('bug')">
t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/> <i class="fa fa-bug me-1"/> Report a Bug
</div> </button>
<button type="button" class="o_fhd_kind_chip"
<!-- Description --> t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }"
<div class="o_fhd_field"> t-on-click="() => this.setKind('feature')">
<label t-esc="state.kind === 'bug' ? 'What were you doing? What did you expect?' : 'Describe the desired behaviour and the use case'"/> <i class="fa fa-lightbulb-o me-1"/> Request a Feature
<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>
</button> </button>
</div> </div>
<div t-if="state.attachments.length" class="o_fhd_attach_list">
<div t-foreach="state.attachments" t-as="att" t-key="att_index" <div class="o_fhd_field">
class="o_fhd_attach_item"> <label>Subject *</label>
<i t-att-class="att.iconClass"/> <input type="text" class="form-control"
<span class="o_fhd_attach_name" t-esc="att.name"/> t-att-value="state.subject"
<span class="o_fhd_attach_size" t-esc="att.sizeLabel"/> t-on-input="(ev) => state.subject = ev.target.value"
<button type="button" class="o_fhd_attach_remove" t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/>
t-on-click="() => this.removeAttachment(att_index)">×</button> </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>
<!-- ===== 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> </div>
</div> </div>
<!-- Result feedback --> <!-- ===== THREAD ===== -->
<div t-if="state.error" class="alert alert-danger mt-2"> <div t-if="state.tab === 'thread'">
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/> <div t-if="state.loadingThread" class="o_fhd_muted text-center p-3">
</div> <i class="fa fa-spinner fa-spin me-1"/> Loading…
<div t-if="state.success" class="alert alert-success mt-2"> </div>
<i class="fa fa-check-circle me-1"/> <t t-elif="state.current">
Thanks — ticket <div class="o_fhd_thread_head">
<a t-att-href="state.ticketUrl" target="_blank"> <span class="o_fhd_ticket_stage" t-esc="state.current.stage"/>
#<t t-esc="state.ticketId"/> <a t-if="state.current.portal_url" class="o_fhd_open_portal"
</a> created<t t-if="state.attached"> with <t t-esc="state.attached"/> attachment(s)</t>. 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>
</div> </div>
<!-- ===== Footer ===== -->
<t t-set-slot="footer"> <t t-set-slot="footer">
<button class="btn btn-primary" <t t-if="state.tab === 'new'">
t-on-click="onSubmit" <button class="btn btn-primary" t-on-click="onSubmit"
t-att-disabled="state.submitting or !state.subject.trim()"> 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-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> <t t-else=""><i class="fa fa-paper-plane me-1"/></t>
Submit Submit
</button> </button>
<button class="btn btn-secondary" t-on-click="props.close"> <button class="btn btn-secondary" t-on-click="props.close">Close</button>
Close </t>
</button> <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> </t>
</Dialog> </Dialog>
</t> </t>

View File

@@ -5,11 +5,14 @@
<div class="o_fhd_systray dropdown"> <div class="o_fhd_systray dropdown">
<button type="button" <button type="button"
class="o_fhd_systray_btn dropdown-toggle" 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"> t-on-click="onClick">
<img src="/fusion_helpdesk/static/description/help_icon.png" <img src="/fusion_helpdesk/static/description/help_icon.png"
alt="Help" alt="Help"
class="o_fhd_systray_img"/> 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> </button>
</div> </div>
</t> </t>