feat(fusion_helpdesk): group My Tickets into Critical/New/Solved sections
The flat write_date-sorted list was hard to scan with 50+ tickets — solved ones were intermixed with active ones, and there was no signal for priority. Bucket each ticket server-side into 'critical' (open + priority High/Urgent), 'solved' (stage marked fold=True on central) or 'open' (everything else), and render three labelled sections in the dialog with sticky headers, count badges, and per-group accent colours. Backend keeps its write_date desc order so latest is always at top within each bucket. Bucketing uses helpdesk.stage.fold (not the stage name) so renaming "Solved" to "Done" on the central won't quietly mis-categorise rows. Adds bucket_ticket() in utils.py with unit tests covering the folded-wins-over-priority precedence and the missing-priority fallback. Also surfaces a small Urgent (triangle) / High (arrow) icon on each row so a critical ticket reads at a glance even after a user scrolls past the section header. Bumps fusion_helpdesk to 19.0.1.6.0.
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.5.0',
|
||||
'version': '19.0.1.6.0',
|
||||
'category': 'Productivity',
|
||||
'summary': 'One-click in-app bug reporting & feature requesting — '
|
||||
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
|
||||
|
||||
@@ -26,6 +26,7 @@ from odoo.http import request
|
||||
from odoo.addons.fusion_helpdesk.utils import (
|
||||
build_ticket_vals,
|
||||
build_scope_domain,
|
||||
bucket_ticket,
|
||||
is_public_message,
|
||||
compute_unread_count,
|
||||
escape_like,
|
||||
@@ -418,6 +419,21 @@ class FusionHelpdeskController(http.Controller):
|
||||
'again in a moment.') % {'code': e.errcode},
|
||||
)
|
||||
|
||||
def _stage_fold_map(self, cfg, tickets):
|
||||
"""{stage_id: fold_bool} for the distinct stages on these tickets.
|
||||
|
||||
`helpdesk.stage.fold` is the central support team's signal for
|
||||
"kanban-folded = closed" — Solved + Cancelled by default. We use it
|
||||
(not the stage name) to bucket tickets into the Solved section, so
|
||||
renaming a stage on central doesn't quietly mis-categorise rows.
|
||||
One extra RPC per list call, batched across all distinct stages."""
|
||||
ids = sorted({t['stage_id'][0] for t in tickets if t.get('stage_id')})
|
||||
if not ids:
|
||||
return {}
|
||||
rows = self._rpc(cfg, 'helpdesk.stage', 'read',
|
||||
[ids], {'fields': ['fold']})
|
||||
return {r['id']: r.get('fold', False) for r in rows}
|
||||
|
||||
def _internal_subtype_map(self, cfg, subtype_ids):
|
||||
"""{subtype_id: internal_bool} so internal notes can be hidden."""
|
||||
ids = [s for s in set(subtype_ids) if s]
|
||||
@@ -562,9 +578,10 @@ class FusionHelpdeskController(http.Controller):
|
||||
tickets = self._rpc(
|
||||
cfg, 'helpdesk.ticket', 'search_read', [domain],
|
||||
{'fields': ['id', 'name', 'stage_id', 'partner_id',
|
||||
'write_date', 'ticket_ref'],
|
||||
'write_date', 'ticket_ref', 'priority'],
|
||||
'order': 'write_date desc', 'limit': 100})
|
||||
msgs = self._ticket_messages(cfg, [t['id'] for t in tickets])
|
||||
stage_fold = self._stage_fold_map(cfg, tickets)
|
||||
except (_RemoteError, xmlrpc.client.Fault, OSError, ssl.SSLError) as e:
|
||||
return self._remote_failure(cfg, e)
|
||||
|
||||
@@ -575,6 +592,7 @@ class FusionHelpdeskController(http.Controller):
|
||||
for t in tickets:
|
||||
rid = t['id']
|
||||
ls = last_support.get(rid, 0)
|
||||
sid = t['stage_id'][0] if t['stage_id'] else None
|
||||
rows.append({
|
||||
'id': rid,
|
||||
'ref': t.get('ticket_ref') or str(rid),
|
||||
@@ -583,6 +601,9 @@ class FusionHelpdeskController(http.Controller):
|
||||
'last_update': t['write_date'],
|
||||
'last_support_msg_id': ls,
|
||||
'has_unread': ls > (seen.get(rid, 0) or 0),
|
||||
'priority': t.get('priority') or '0',
|
||||
'group': bucket_ticket(stage_fold.get(sid, False),
|
||||
t.get('priority')),
|
||||
})
|
||||
return {'ok': True, 'tickets': rows, 'is_admin': ident['is_admin'],
|
||||
'unread': compute_unread_count(rows, seen)}
|
||||
|
||||
@@ -124,6 +124,29 @@ export class FusionHelpdeskDialog extends Component {
|
||||
await this.loadList();
|
||||
}
|
||||
|
||||
// Bucket tickets into Critical / New & Open / Solved for the section view.
|
||||
// Backend already sorts by write_date desc, so iterating preserves latest-
|
||||
// first within each bucket. We only render non-empty sections so the
|
||||
// dialog doesn't show "0 Critical" noise when the user has none.
|
||||
get groupedTickets() {
|
||||
const groups = [
|
||||
{ key: "critical", title: _t("Critical"),
|
||||
iconClass: "fa fa-exclamation-circle", className: "o_fhd_group_critical",
|
||||
tickets: [] },
|
||||
{ key: "open", title: _t("New & Open"),
|
||||
iconClass: "fa fa-inbox", className: "o_fhd_group_open",
|
||||
tickets: [] },
|
||||
{ key: "solved", title: _t("Solved"),
|
||||
iconClass: "fa fa-check-circle", className: "o_fhd_group_solved",
|
||||
tickets: [] },
|
||||
];
|
||||
const byKey = Object.fromEntries(groups.map((g) => [g.key, g]));
|
||||
for (const t of this.state.tickets) {
|
||||
(byKey[t.group] || byKey.open).tickets.push(t);
|
||||
}
|
||||
return groups.filter((g) => g.tickets.length);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// My Tickets — thread
|
||||
// ==================================================================
|
||||
|
||||
@@ -226,6 +226,62 @@ $fhd-accent: var(--fhd-accent, $_fhd-accent-hex);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
// Ticket groups (Critical / New & Open / Solved) — render a stack of
|
||||
// labelled sections; backend already sorts each by latest-first.
|
||||
.o_fhd_ticket_groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px; // breathing room beside the scrollbar
|
||||
}
|
||||
|
||||
.o_fhd_group_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: $fhd-bg;
|
||||
border-bottom: 1px solid $fhd-border;
|
||||
}
|
||||
|
||||
.o_fhd_group_count {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background-color: $fhd-hover;
|
||||
border: 1px solid $fhd-border;
|
||||
color: $fhd-muted;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// Per-group accent colours on the header — kept subtle so the list rows
|
||||
// stay the visual focus. The Critical icon is the only saturated colour.
|
||||
.o_fhd_group_critical .o_fhd_group_header { color: #d9534f; }
|
||||
.o_fhd_group_critical .o_fhd_group_header > i { color: #d9534f; }
|
||||
.o_fhd_group_open .o_fhd_group_header > i { color: $fhd-accent; }
|
||||
.o_fhd_group_solved .o_fhd_group_header { color: $fhd-muted; }
|
||||
.o_fhd_group_solved .o_fhd_group_header > i { color: #4CAF50; }
|
||||
|
||||
// Priority indicator inline in the row — Urgent + High only; Low/Normal
|
||||
// get nothing so 90% of rows stay quiet.
|
||||
.o_fhd_priority_urgent, .o_fhd_priority_high {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.o_fhd_priority_urgent { color: #d9534f; }
|
||||
.o_fhd_priority_high { color: #f0ad4e; }
|
||||
|
||||
// Ticket list
|
||||
.o_fhd_ticket_list {
|
||||
display: flex;
|
||||
|
||||
@@ -138,14 +138,30 @@
|
||||
<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 t-else="" class="o_fhd_ticket_groups">
|
||||
<div t-foreach="groupedTickets" t-as="group" t-key="group.key"
|
||||
t-attf-class="o_fhd_group {{ group.className }}">
|
||||
<div class="o_fhd_group_header">
|
||||
<i t-att-class="group.iconClass"/>
|
||||
<span class="o_fhd_group_title" t-esc="group.title"/>
|
||||
<span class="o_fhd_group_count" t-esc="group.tickets.length"/>
|
||||
</div>
|
||||
<div class="o_fhd_ticket_list">
|
||||
<div t-foreach="group.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 t-if="t.priority === '3'" class="o_fhd_priority_urgent" title="Urgent">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
</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"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ from odoo.tests import TransactionCase, tagged
|
||||
from odoo.addons.fusion_helpdesk.utils import (
|
||||
build_ticket_vals,
|
||||
build_scope_domain,
|
||||
bucket_ticket,
|
||||
is_public_message,
|
||||
compute_unread_count,
|
||||
_norm_email,
|
||||
@@ -129,3 +130,32 @@ class TestNormEmail(TransactionCase):
|
||||
# every inbox endpoint. Guard that the name is resolvable there.
|
||||
from odoo.addons.fusion_helpdesk.controllers import main
|
||||
self.assertTrue(hasattr(main, '_norm_email'))
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||
class TestBucketTicket(TransactionCase):
|
||||
"""Bucketing pins how the My Tickets inbox sections work — getting this
|
||||
wrong used to scatter Solved tickets in with New ones, defeating the
|
||||
whole point of the grouped view."""
|
||||
|
||||
def test_folded_stage_goes_to_solved(self):
|
||||
# Folded wins even if priority is high — closed tickets aren't
|
||||
# actionable so they belong in Solved regardless.
|
||||
self.assertEqual(bucket_ticket(True, '0'), 'solved')
|
||||
self.assertEqual(bucket_ticket(True, '3'), 'solved')
|
||||
|
||||
def test_open_high_or_urgent_promotes_to_critical(self):
|
||||
self.assertEqual(bucket_ticket(False, '2'), 'critical')
|
||||
self.assertEqual(bucket_ticket(False, '3'), 'critical')
|
||||
|
||||
def test_open_low_or_normal_is_just_open(self):
|
||||
self.assertEqual(bucket_ticket(False, '0'), 'open')
|
||||
self.assertEqual(bucket_ticket(False, '1'), 'open')
|
||||
self.assertEqual(bucket_ticket(False, None), 'open') # missing -> normal
|
||||
self.assertEqual(bucket_ticket(False, ''), 'open')
|
||||
|
||||
def test_controller_namespace_resolves_bucket_ticket(self):
|
||||
# Regression: the inbox controller imports bucket_ticket — if the
|
||||
# name disappears from utils.py, every list call would 500.
|
||||
from odoo.addons.fusion_helpdesk.controllers import main
|
||||
self.assertTrue(hasattr(main, 'bucket_ticket'))
|
||||
|
||||
@@ -101,6 +101,22 @@ def is_public_message(msg):
|
||||
return not msg.get('subtype_is_internal', False)
|
||||
|
||||
|
||||
def bucket_ticket(stage_is_folded, priority):
|
||||
"""Bucket key for the "My Tickets" inbox: 'critical' | 'solved' | 'open'.
|
||||
|
||||
Folded stages (Solved, Cancelled — whatever the central support team has
|
||||
marked as kanban-folded) collapse into the 'solved' bucket regardless of
|
||||
priority, because a closed ticket is not actionable. For everything still
|
||||
open, High and Urgent (priority '2'/'3') promote to 'critical' so the
|
||||
reporter can find blockers without scrolling. Anything else is 'open'.
|
||||
"""
|
||||
if stage_is_folded:
|
||||
return 'solved'
|
||||
if (priority or '0') in ('2', '3'):
|
||||
return 'critical'
|
||||
return 'open'
|
||||
|
||||
|
||||
def compute_unread_count(tickets, seen_by_id):
|
||||
"""Number of tickets with a support reply the user hasn't seen.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user