This commit is contained in:
gsinghpal
2026-05-04 02:14:34 -04:00
parent 3cc393454d
commit 586f05d567
43 changed files with 3656 additions and 112 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,244 @@
/** @odoo-module **/
// Fusion Helpdesk — submission dialog. Lets the user pick Bug or
// Feature, fill in subject + description, paste an error code, attach
// files, and capture a screenshot via the browser's getDisplayMedia
// API. On submit, the payload is POSTed to /fusion_helpdesk/submit
// which forwards it (XML-RPC) to a central Odoo Helpdesk.
import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
const MAX_BYTES_PER_FILE = 10 * 1024 * 1024; // 10 MB hard cap per file
export class FusionHelpdeskDialog extends Component {
static template = "fusion_helpdesk.Dialog";
static components = { Dialog };
static props = {
close: Function,
};
setup() {
this.notification = useService("notification");
this.state = useState({
kind: "bug", // 'bug' | 'feature'
subject: "",
description: "",
errorCode: "",
attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}]
capturing: false,
submitting: false,
error: "",
success: false,
ticketId: null,
ticketUrl: "",
attached: 0,
});
}
get dialogTitle() {
return this.state.kind === "bug"
? _t("Report a Bug")
: _t("Request a Feature");
}
setKind(kind) {
this.state.kind = kind;
}
// ------------------------------------------------------------------
// File input → b64
async onFilesPicked(ev) {
const files = Array.from(ev.target.files || []);
for (const f of files) {
if (f.size > MAX_BYTES_PER_FILE) {
this.notification.add(
_t("File '%s' is over 10 MB and was skipped.").replace("%s", f.name),
{ type: "warning" }
);
continue;
}
try {
const b64 = await this._fileToB64(f);
this._addAttachment({
name: f.name,
mimetype: f.type || "application/octet-stream",
data_b64: b64,
rawSize: f.size,
});
} catch (err) {
this.notification.add(
_t("Could not read '%s'.").replace("%s", f.name),
{ type: "danger" }
);
}
}
// Reset the input so picking the same file again re-fires onchange.
ev.target.value = "";
}
_fileToB64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result || "";
const idx = result.indexOf(",");
resolve(idx >= 0 ? result.slice(idx + 1) : result);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// ------------------------------------------------------------------
// Screenshot capture via getDisplayMedia
async onTakeScreenshot() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
this.notification.add(
_t("Your browser doesn't support screen capture. Use Attach files instead."),
{ type: "warning" }
);
return;
}
this.state.capturing = true;
let stream;
try {
stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: "browser" },
audio: false,
});
const blob = await this._streamToBlob(stream);
const b64 = await this._blobToB64(blob);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
this._addAttachment({
name: `screenshot-${ts}.png`,
mimetype: "image/png",
data_b64: b64,
rawSize: blob.size,
});
} catch (err) {
// User cancelled the picker — silently swallow. Other errors → notify.
if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") {
this.notification.add(
_t("Screenshot failed: %s").replace("%s", err.message || err),
{ type: "danger" }
);
}
} finally {
if (stream) {
stream.getTracks().forEach((t) => t.stop());
}
this.state.capturing = false;
}
}
async _streamToBlob(stream) {
const video = document.createElement("video");
video.srcObject = stream;
await video.play();
// Give the browser one frame to settle the picker chrome.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
return await new Promise((resolve) =>
canvas.toBlob((b) => resolve(b), "image/png", 0.92)
);
}
_blobToB64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result || "";
const idx = result.indexOf(",");
resolve(idx >= 0 ? result.slice(idx + 1) : result);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
_addAttachment({ name, mimetype, data_b64, rawSize }) {
this.state.attachments.push({
name,
mimetype,
data_b64,
sizeLabel: this._formatBytes(rawSize),
iconClass: this._iconForMime(mimetype),
});
}
removeAttachment(idx) {
this.state.attachments.splice(idx, 1);
}
_formatBytes(n) {
if (!n) return "";
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
return (n / (1024 * 1024)).toFixed(1) + " MB";
}
_iconForMime(mt) {
mt = (mt || "").toLowerCase();
if (mt.startsWith("image/")) return "fa fa-file-image-o";
if (mt.startsWith("video/")) return "fa fa-file-video-o";
if (mt.startsWith("audio/")) return "fa fa-file-audio-o";
if (mt.includes("pdf")) return "fa fa-file-pdf-o";
if (mt.includes("zip") || mt.includes("tar") || mt.includes("rar")) return "fa fa-file-archive-o";
if (mt.includes("text")) return "fa fa-file-text-o";
return "fa fa-file-o";
}
// ------------------------------------------------------------------
// Submit
async onSubmit() {
if (this.state.submitting) return;
const subject = (this.state.subject || "").trim();
if (!subject) {
this.state.error = _t("Subject is required.");
return;
}
this.state.error = "";
this.state.success = false;
this.state.submitting = true;
try {
const payload = {
kind: this.state.kind,
subject,
description: this.state.description || "",
error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "",
attachments: this.state.attachments.map((a) => ({
name: a.name,
mimetype: a.mimetype,
data_b64: a.data_b64,
})),
page_url: window.location.href,
user_agent: navigator.userAgent,
};
const res = await rpc("/fusion_helpdesk/submit", payload);
if (!res.ok) {
this.state.error = res.message || _t("Submission failed.");
} else {
this.state.success = true;
this.state.ticketId = res.ticket_id;
this.state.ticketUrl = res.ticket_url;
this.state.attached = res.attached || 0;
// Reset the editable fields so user can file another if they want.
this.state.subject = "";
this.state.description = "";
this.state.errorCode = "";
this.state.attachments = [];
}
} catch (err) {
this.state.error = (err && err.message) || _t("Network error.");
} finally {
this.state.submitting = false;
}
}
}

View File

@@ -0,0 +1,33 @@
/** @odoo-module **/
// Fusion Helpdesk — top systray icon. Sequence chosen so the icon
// appears to the LEFT of the attendance check-in button. Odoo
// systray ordering is by sequence ascending (lower = leftmost in the
// systray bar). hr_attendance ships at sequence 100, so we use 99.
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog";
class FusionHelpdeskSystray extends Component {
static template = "fusion_helpdesk.SystrayItem";
static props = {};
setup() {
this.dialog = useService("dialog");
}
onClick() {
this.dialog.add(FusionHelpdeskDialog, {});
}
}
export const fusionHelpdeskSystrayItem = {
Component: FusionHelpdeskSystray,
};
registry
.category("systray")
.add("fusion_helpdesk.report_button", fusionHelpdeskSystrayItem, {
sequence: 99,
});

View File

@@ -0,0 +1,172 @@
// Fusion Helpdesk Reporter — systray button + dialog styling.
// Dark-mode aware via Odoo's $o-webclient-color-scheme branch.
$o-webclient-color-scheme: bright !default;
$_fhd-text-hex: #21252b;
$_fhd-muted-hex: #6c757d;
$_fhd-bg-hex: #ffffff;
$_fhd-border-hex: #d8dadd;
$_fhd-hover-hex: #f3f4f6;
$_fhd-accent-hex: #2c89e9;
@if $o-webclient-color-scheme == dark {
$_fhd-text-hex: #e6e9ef !global;
$_fhd-muted-hex: #9aa3ad !global;
$_fhd-bg-hex: #22262d !global;
$_fhd-border-hex: #3a3f47 !global;
$_fhd-hover-hex: #2c313a !global;
$_fhd-accent-hex: #4ea3ff !global;
}
$fhd-text: var(--fhd-text, $_fhd-text-hex);
$fhd-muted: var(--fhd-muted, $_fhd-muted-hex);
$fhd-bg: var(--fhd-bg, $_fhd-bg-hex);
$fhd-border: var(--fhd-border, $_fhd-border-hex);
$fhd-hover: var(--fhd-hover, $_fhd-hover-hex);
$fhd-accent: var(--fhd-accent, $_fhd-accent-hex);
// Systray icon
.o_fhd_systray {
.o_fhd_systray_btn {
background: transparent;
border: none;
padding: 0 0.5rem;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
&:hover .o_fhd_systray_img {
transform: scale(1.08);
filter: drop-shadow(0 0 2px rgba(78, 163, 255, 0.45));
}
}
.o_fhd_systray_img {
height: 22px;
width: 22px;
object-fit: contain;
transition: transform 120ms ease, filter 120ms ease;
}
// Hide the dropdown caret Bootstrap injects.
.dropdown-toggle::after { display: none; }
}
// Dialog
.o_fhd_dialog {
color: $fhd-text;
.o_fhd_kind_row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.o_fhd_kind_chip {
flex: 1;
padding: 0.6rem 0.75rem;
background-color: $fhd-bg;
border: 1px solid $fhd-border;
border-radius: 6px;
color: $fhd-text;
cursor: pointer;
font-weight: 500;
&:hover { background-color: $fhd-hover; }
&.o_fhd_kind_active {
border-color: $fhd-accent;
color: $fhd-accent;
background-color: rgba(44, 137, 233, 0.10);
}
}
.o_fhd_field {
margin-bottom: 0.85rem;
> label {
display: block;
font-weight: 500;
margin-bottom: 0.25rem;
color: $fhd-text;
}
}
.o_fhd_hint {
font-weight: 400;
font-size: 0.8rem;
color: $fhd-muted;
margin-left: 0.4rem;
}
.o_fhd_mono {
font-family: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
font-size: 0.85rem;
}
.o_fhd_actions_row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.o_fhd_btn {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
border: 1px solid $fhd-border;
background-color: $fhd-bg;
color: $fhd-text;
&:hover:not(:disabled) { background-color: $fhd-hover; }
&:disabled { opacity: 0.6; cursor: default; }
}
.o_fhd_attach_list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 0.5rem;
}
.o_fhd_attach_item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
background-color: $fhd-hover;
border: 1px solid $fhd-border;
border-radius: 4px;
font-size: 0.85rem;
}
.o_fhd_attach_name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.o_fhd_attach_size {
color: $fhd-muted;
font-variant-numeric: tabular-nums;
}
.o_fhd_attach_remove {
background: transparent;
border: none;
color: $fhd-muted;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
padding: 0 0.25rem;
&:hover { color: #d32f2f; }
}
}

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<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
</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>
<!-- 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>
</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>
<!-- 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>.
</div>
</div>
<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>
</Dialog>
</t>
</templates>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_helpdesk.SystrayItem">
<div class="o_fhd_systray dropdown">
<button type="button"
class="o_fhd_systray_btn dropdown-toggle"
title="Report a bug or request a feature"
t-on-click="onClick">
<img src="/fusion_helpdesk/static/description/icon.png"
alt="Help"
class="o_fhd_systray_img"/>
</button>
</div>
</t>
</templates>