feat(fusion_helpdesk): Critical flag, KPI cards, colored stage pills
Three coordinated changes on top of the section grouping: 1. **Mark as Critical** — a red chip on the New tab sets priority='3' when submitted. The central post-create hook auto-applies a "Critical" helpdesk.tag (shipped via fusion_helpdesk_central data XML, noupdate=1 so support can recolor without losing it on upgrade), giving support a kanban-groupable signal that doesn't rely on remembering what priority='3' means. Scoped to in-app-channel tickets only, so a support agent manually setting Urgent on their own ticket isn't silently tagged. 2. **KPI cards above the sections** — Total / Open / Closed / Critical in a 4-up grid (auto-collapses to 2x2 under 540px). Each card uses its own saturated gradient so it reads on both light and dark mode — the dialog backdrop is irrelevant because the gradient brings its own background. Counts are computed in JS from state.tickets so they always match what's rendered below. 3. **Colored stage pills** — red Critical, green Solved, dark-yellow New, orange Cancelled, blue for In Progress / Testing / On Hold. Critical priority gets a *separate* red pill alongside the stage pill so you keep stage info even on escalated tickets. Stage matching is substring-based (lowercased) so a renamed "Resolved" or "Done" stage on central still maps to the green pill. Tests cover the new is_critical=True → priority='3' wiring and the default omission so SLA / stage defaults keep working for normal tickets. Bumps fusion_helpdesk to 19.0.1.7.0 and fusion_helpdesk_central to 19.0.1.2.0. End-to-end smoke test verified live: priority=3 + x_fc_client_label triggers the Critical tag.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Helpdesk Reporter',
|
||||
'version': '19.0.1.6.0',
|
||||
'version': '19.0.1.7.0',
|
||||
'category': 'Productivity',
|
||||
'summary': 'One-click in-app bug reporting & feature requesting — '
|
||||
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
|
||||
|
||||
@@ -44,7 +44,8 @@ class FusionHelpdeskController(http.Controller):
|
||||
)
|
||||
def submit(self, kind, subject, description,
|
||||
error_code=None, attachments=None,
|
||||
page_url=None, user_agent=None, reply_email=None):
|
||||
page_url=None, user_agent=None, reply_email=None,
|
||||
is_critical=False):
|
||||
"""Forward a bug report or feature request to the central Odoo
|
||||
Helpdesk and return {ok, ticket_id, ticket_url, error}.
|
||||
|
||||
@@ -98,6 +99,7 @@ class FusionHelpdeskController(http.Controller):
|
||||
team_id=cfg['team_id'], client_label=cfg['client_label'],
|
||||
reporter_name=user.name, reporter_email=reporter_email,
|
||||
company_name=request.env.company.name,
|
||||
is_critical=bool(is_critical),
|
||||
)
|
||||
|
||||
# ---- Talk to remote Odoo --------------------------------------
|
||||
|
||||
@@ -39,6 +39,7 @@ export class FusionHelpdeskDialog extends Component {
|
||||
errorCode: "",
|
||||
replyEmail: user.login || "",
|
||||
attachments: [],
|
||||
isCritical: false,
|
||||
capturing: false,
|
||||
submitting: false,
|
||||
error: "",
|
||||
@@ -147,6 +148,38 @@ export class FusionHelpdeskDialog extends Component {
|
||||
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
|
||||
// ==================================================================
|
||||
@@ -369,6 +402,7 @@ export class FusionHelpdeskDialog extends Component {
|
||||
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,
|
||||
@@ -390,6 +424,7 @@ export class FusionHelpdeskDialog extends Component {
|
||||
this.state.description = "";
|
||||
this.state.errorCode = "";
|
||||
this.state.attachments = [];
|
||||
this.state.isCritical = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("fusion_helpdesk: submit failed", err);
|
||||
|
||||
@@ -337,6 +337,139 @@ $fhd-accent: var(--fhd-accent, $_fhd-accent-hex);
|
||||
color: $fhd-muted;
|
||||
}
|
||||
|
||||
// Colour pills — readable on both light and dark mode because each pill
|
||||
// carries its own background (saturated brand colour) + white text. The
|
||||
// 1px border darkens to keep the edge crisp on both backdrops.
|
||||
.o_fhd_pill {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 10px;
|
||||
background-color: $fhd-hover;
|
||||
border: 1px solid $fhd-border;
|
||||
color: $fhd-muted;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.o_fhd_pill_critical {
|
||||
background: linear-gradient(135deg, #e85d68 0%, #dc3545 100%);
|
||||
border-color: #b02a37;
|
||||
color: #fff;
|
||||
}
|
||||
.o_fhd_pill_solved {
|
||||
background: linear-gradient(135deg, #5cc66f 0%, #28a745 100%);
|
||||
border-color: #1e7e34;
|
||||
color: #fff;
|
||||
}
|
||||
.o_fhd_pill_new {
|
||||
// "Dark Yellow" — closer to amber/mustard so it sits well next to red/green.
|
||||
background: linear-gradient(135deg, #e9b949 0%, #c79100 100%);
|
||||
border-color: #a07700;
|
||||
color: #fff;
|
||||
}
|
||||
.o_fhd_pill_cancelled {
|
||||
background: linear-gradient(135deg, #ff9a3c 0%, #fd7e14 100%);
|
||||
border-color: #c85a00;
|
||||
color: #fff;
|
||||
}
|
||||
.o_fhd_pill_progress {
|
||||
background: linear-gradient(135deg, #4ea3ff 0%, #2c89e9 100%);
|
||||
border-color: #1e6bbf;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
// KPI cards — Total / Open / Closed / Critical. Each card uses its own
|
||||
// saturated gradient so they read on both light and dark mode (the card
|
||||
// brings its own background, the dialog backdrop doesn't matter).
|
||||
.o_fhd_kpi_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media (max-width: 540px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fhd_kpi_card {
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// Subtle inner highlight on top so the gradient reads as raised, not flat.
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.10) 0%, rgba(255, 255, 255, 0) 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fhd_kpi_label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.9;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.o_fhd_kpi_value {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.05;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.o_fhd_kpi_total { background: linear-gradient(135deg, #6fb8ff 0%, #2c89e9 60%, #1e6bbf 100%); }
|
||||
.o_fhd_kpi_open { background: linear-gradient(135deg, #f2c75c 0%, #d4a017 60%, #a07700 100%); }
|
||||
.o_fhd_kpi_closed { background: linear-gradient(135deg, #6cd17f 0%, #28a745 60%, #1e7e34 100%); }
|
||||
.o_fhd_kpi_critical { background: linear-gradient(135deg, #ff7681 0%, #dc3545 60%, #9b1f2b 100%); }
|
||||
|
||||
// "Mark as Critical" chip in the New tab — dormant by default, glows red
|
||||
// once active so the user knows the flag is set before they submit.
|
||||
.o_fhd_critical_row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.o_fhd_critical_chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
background-color: $fhd-bg;
|
||||
border: 1px solid $fhd-border;
|
||||
color: $fhd-text;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
|
||||
&:hover { background-color: $fhd-hover; }
|
||||
|
||||
&.o_fhd_critical_active {
|
||||
background: linear-gradient(135deg, #e85d68 0%, #dc3545 100%);
|
||||
border-color: #b02a37;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
// Thread
|
||||
.o_fhd_thread_head {
|
||||
display: flex;
|
||||
|
||||
@@ -72,6 +72,23 @@
|
||||
placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fhd_field">
|
||||
<label>Severity</label>
|
||||
<div class="o_fhd_critical_row">
|
||||
<button type="button"
|
||||
class="o_fhd_critical_chip"
|
||||
t-att-class="{ 'o_fhd_critical_active': state.isCritical }"
|
||||
t-on-click="() => state.isCritical = !state.isCritical">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<t t-if="state.isCritical">Marked Critical</t>
|
||||
<t t-else="">Mark as Critical</t>
|
||||
</button>
|
||||
<span class="o_fhd_hint">
|
||||
Flag this if it's blocking production work — support gets paged.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fhd_field">
|
||||
<label>Attachments</label>
|
||||
<div class="o_fhd_actions_row">
|
||||
@@ -128,6 +145,32 @@
|
||||
t-on-click="() => this.setScope('all')">All (deployment)</button>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
KPI cards — Total / Open / Closed / Critical. Counts come from
|
||||
the same `state.tickets` the sections render, so the numbers
|
||||
always match what's on screen. The grid auto-wraps to two
|
||||
columns on narrow dialogs.
|
||||
-->
|
||||
<div t-if="!state.loadingList and !state.listError and state.tickets.length"
|
||||
class="o_fhd_kpi_grid">
|
||||
<div class="o_fhd_kpi_card o_fhd_kpi_total">
|
||||
<div class="o_fhd_kpi_label">Total</div>
|
||||
<div class="o_fhd_kpi_value" t-esc="ticketStats.total"/>
|
||||
</div>
|
||||
<div class="o_fhd_kpi_card o_fhd_kpi_open">
|
||||
<div class="o_fhd_kpi_label">Open</div>
|
||||
<div class="o_fhd_kpi_value" t-esc="ticketStats.open"/>
|
||||
</div>
|
||||
<div class="o_fhd_kpi_card o_fhd_kpi_closed">
|
||||
<div class="o_fhd_kpi_label">Closed</div>
|
||||
<div class="o_fhd_kpi_value" t-esc="ticketStats.closed"/>
|
||||
</div>
|
||||
<div class="o_fhd_kpi_card o_fhd_kpi_critical">
|
||||
<div class="o_fhd_kpi_label">Critical</div>
|
||||
<div class="o_fhd_kpi_value" t-esc="ticketStats.critical"/>
|
||||
</div>
|
||||
</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>
|
||||
@@ -153,13 +196,10 @@
|
||||
<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 t-if="t.priority === '3'" class="o_fhd_priority_urgent" title="Urgent">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<span t-if="isCriticalRow(t)" class="o_fhd_pill o_fhd_pill_critical" title="Critical priority">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>Critical
|
||||
</span>
|
||||
<span t-elif="t.priority === '2'" class="o_fhd_priority_high" title="High">
|
||||
<i class="fa fa-arrow-up"/>
|
||||
</span>
|
||||
<span class="o_fhd_ticket_stage" t-esc="t.stage"/>
|
||||
<span t-att-class="pillClass(t)" t-esc="t.stage"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,27 @@ class TestBuildTicketVals(TransactionCase):
|
||||
self.assertEqual(vals['partner_name'], 'Jane')
|
||||
self.assertIn('Feature Request', vals['name'])
|
||||
|
||||
def test_is_critical_sets_urgent_priority(self):
|
||||
# The "Mark as Critical" toggle on the dialog turns into priority='3'
|
||||
# in the create vals — that's the load-bearing signal both for the
|
||||
# inbox's Critical bucket AND the central auto-tag.
|
||||
vals = build_ticket_vals(
|
||||
kind='bug', subject='X', body_html='<p>b</p>', team_id=1,
|
||||
client_label='ENTECH', reporter_name='X', reporter_email='x@x.com',
|
||||
company_name='X Inc', is_critical=True,
|
||||
)
|
||||
self.assertEqual(vals.get('priority'), '3')
|
||||
|
||||
def test_is_critical_default_omits_priority(self):
|
||||
# Default path must NOT set priority so the central stage / SLA default
|
||||
# keeps working — only the explicit toggle escalates.
|
||||
vals = build_ticket_vals(
|
||||
kind='bug', subject='X', body_html='<p>b</p>', team_id=1,
|
||||
client_label='ENTECH', reporter_name='X', reporter_email='x@x.com',
|
||||
company_name='X Inc',
|
||||
)
|
||||
self.assertNotIn('priority', vals)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||
class TestScopeDomain(TransactionCase):
|
||||
|
||||
@@ -49,13 +49,19 @@ def _norm_email(*candidates):
|
||||
|
||||
|
||||
def build_ticket_vals(kind, subject, body_html, team_id, client_label,
|
||||
reporter_name, reporter_email, company_name):
|
||||
reporter_name, reporter_email, company_name,
|
||||
is_critical=False):
|
||||
"""Construct the `helpdesk.ticket` create vals for a forwarded report.
|
||||
|
||||
The identity fields (`partner_email`, `partner_name`,
|
||||
`partner_company_name`) drive native helpdesk find-or-create of the
|
||||
customer partner + follower subscription on the central Odoo, and
|
||||
`x_fc_client_label` tags the deployment for the scoped inbox.
|
||||
|
||||
When `is_critical` is set, we pass `priority='3'` (Urgent) — the central
|
||||
post-create hook then auto-applies the Critical helpdesk.tag so support
|
||||
can filter by it. Priority drives the inbox's Critical section too
|
||||
(see `bucket_ticket`), so the same toggle is load-bearing on both ends.
|
||||
"""
|
||||
kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
|
||||
prefix = ('[%s] ' % client_label) if client_label else ''
|
||||
@@ -72,6 +78,8 @@ def build_ticket_vals(kind, subject, body_html, team_id, client_label,
|
||||
vals['partner_company_name'] = company_name
|
||||
if client_label:
|
||||
vals['x_fc_client_label'] = client_label
|
||||
if is_critical:
|
||||
vals['priority'] = '3'
|
||||
return vals
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1
|
||||
{
|
||||
'name': 'Fusion Helpdesk Central — Client API Keys',
|
||||
'version': '19.0.1.1.0',
|
||||
'version': '19.0.1.2.0',
|
||||
'category': 'Productivity',
|
||||
'summary': 'Admin UI on the central Odoo for issuing per-client API '
|
||||
'keys used by fusion_helpdesk client deployments.',
|
||||
@@ -29,6 +29,7 @@ Depends only on `helpdesk`. No client-side install needed.
|
||||
'security/ir.model.access.csv',
|
||||
'data/ir_config_parameter_data.xml',
|
||||
'data/mail_template_ack.xml',
|
||||
'data/helpdesk_tag_critical.xml',
|
||||
'views/fusion_helpdesk_client_key_views.xml',
|
||||
'views/helpdesk_ticket_views.xml',
|
||||
],
|
||||
|
||||
24
fusion_helpdesk_central/data/helpdesk_tag_critical.xml
Normal file
24
fusion_helpdesk_central/data/helpdesk_tag_critical.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1
|
||||
-->
|
||||
<odoo>
|
||||
<!--
|
||||
Critical tag for in-app helpdesk tickets. The client-side dialog
|
||||
offers a "Mark as Critical" toggle on the New tab; when set, the
|
||||
ticket is created with priority='3' (Urgent), and the central
|
||||
post-create hook in helpdesk_ticket.py auto-applies this tag so
|
||||
support can filter / kanban-group by it without remembering which
|
||||
priority value means what. noupdate=1 protects support's color
|
||||
choice from being reset by every -u fusion_helpdesk_central.
|
||||
(noupdate lives on <data>, not on <record> — the RelaxNG schema
|
||||
rejects it on records in Odoo 19.)
|
||||
-->
|
||||
<data noupdate="1">
|
||||
<record id="tag_critical" model="helpdesk.tag">
|
||||
<field name="name">Critical</field>
|
||||
<field name="color">1</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -28,8 +28,34 @@ class HelpdeskTicket(models.Model):
|
||||
def create(self, vals_list):
|
||||
tickets = super().create(vals_list)
|
||||
tickets._fc_send_ack_email()
|
||||
tickets._fc_auto_tag_critical()
|
||||
return tickets
|
||||
|
||||
def _fc_auto_tag_critical(self):
|
||||
"""Auto-apply the Critical tag on in-app tickets that were filed with
|
||||
priority='3' (Urgent — the client-side "Mark as Critical" toggle).
|
||||
|
||||
Scoped to tickets carrying `x_fc_client_label` so support staff who
|
||||
manually set priority='3' on their own internal tickets aren't
|
||||
silently tagged. Best-effort: if the data XML hasn't loaded the tag
|
||||
yet (e.g. partial install), skip without raising — the ticket is
|
||||
already filed with priority='3' which is the load-bearing signal."""
|
||||
critical = self.filtered(
|
||||
lambda t: t.priority == '3' and t.x_fc_client_label
|
||||
)
|
||||
if not critical:
|
||||
return
|
||||
tag = self.env.ref(
|
||||
'fusion_helpdesk_central.tag_critical', raise_if_not_found=False,
|
||||
)
|
||||
if not tag:
|
||||
_logger.warning(
|
||||
'fusion_helpdesk_central: tag_critical not found, skipping '
|
||||
'auto-tag on %s critical ticket(s).', len(critical),
|
||||
)
|
||||
return
|
||||
critical.write({'tag_ids': [(4, tag.id)]})
|
||||
|
||||
def _fc_send_ack_email(self):
|
||||
"""Send the branded acknowledgement (with magic link) to the customer.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user