feat: recent activity
This commit is contained in:
@@ -157,8 +157,12 @@ apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
|
||||
### Icons
|
||||
|
||||
- **Primary icon system**: Lucide icons via unplugin-icons for consistency and modern design
|
||||
- **Secondary**: FeatherIcon component from frappe-ui (legacy usage, prefer Lucide for new components)
|
||||
- Use Lucide icons for all new components:
|
||||
- **Secondary**: FeatherIcon component from frappe-ui (legacy usage only)
|
||||
- **Rule**: Never introduce `FeatherIcon` in new code. New components must use Lucide
|
||||
icons (`<LucideInfo />`, `<LucidePlus />`, …) — no exceptions, including small
|
||||
helper icons like tooltip/info glyphs.
|
||||
- Use Lucide icons for all new components. They are globally auto-registered
|
||||
(see `desk/components.d.ts`), so no import is needed when used as a template tag:
|
||||
```vue
|
||||
<template>
|
||||
<LucidePlus class="size-4" />
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
<SidebarLabel
|
||||
v-if="section.label"
|
||||
divider
|
||||
class="my-1 select-none"
|
||||
class="mt-4 my-1 select-none"
|
||||
:class="section.collapsible && !isCollapsed && 'cursor-pointer'"
|
||||
@click="section.collapsible && toggleSection(section.label)"
|
||||
>
|
||||
<span class="flex items-center gap-1.5 text-sm font-medium">
|
||||
<span
|
||||
class="lucide-chevron-right size-4 shrink-0 text-ink-gray-9 transition-transform duration-300 ease-in-out"
|
||||
class="lucide-chevron-right size-4 shrink-0 text-ink-gray-9 transition-transform duration-300 ease-in-out -ml-0.5"
|
||||
:class="{ 'rotate-90': isSectionOpen(section.label) }"
|
||||
/>
|
||||
<span class="truncate">{{ section.label }}</span>
|
||||
|
||||
@@ -361,6 +361,18 @@ const chartsDropdown = computed(() => {
|
||||
maxH: 32,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: __("Recent Activity"),
|
||||
chart: "recent_activity",
|
||||
onClick: () =>
|
||||
addChart("recent_activity", {
|
||||
w: 16,
|
||||
h: 32,
|
||||
minW: 12,
|
||||
minH: 20,
|
||||
maxH: 44,
|
||||
}),
|
||||
},
|
||||
].filter((chart) => {
|
||||
return !layout.value.some((item) => item.chart === chart.chart);
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<RecentFeedback v-if="item.chart == 'recent_feedback'" :data="item.data" />
|
||||
<AvgTimeMetrics v-if="item.chart == 'avg_time_metrics'" :data="item.data" />
|
||||
<PendingTickets v-if="item.chart == 'pending_tickets'" :data="item.data" />
|
||||
<RecentActivity v-if="item.chart == 'recent_activity'" :data="item.data" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -34,6 +35,7 @@ import LineChartCard from "../../../components/LineChartCard.vue";
|
||||
import AgentTicketsCard from "./AgentTicketsCard.vue";
|
||||
import AvgTimeMetrics from "./AvgTimeMetrics.vue";
|
||||
import PendingTickets from "./PendingTickets.vue";
|
||||
import RecentActivity from "./RecentActivity.vue";
|
||||
import RecentFeedback from "./RecentFeedback.vue";
|
||||
|
||||
interface Item {
|
||||
|
||||
149
desk/src/pages/home/components/RecentActivity.vue
Normal file
149
desk/src/pages/home/components/RecentActivity.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col rounded-md px-2 py-4 grow w-full h-full overflow-hidden"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-2 text-lg-semibold text-ink-gray-8">
|
||||
{{ __("Recent Activity") }}
|
||||
<Tooltip
|
||||
:text="__('Tickets you\'ve recently worked on or viewed')"
|
||||
placement="top"
|
||||
>
|
||||
<LucideInfo class="size-3.5 cursor-pointer text-ink-gray-5" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="relative mt-5 grow overflow-hidden">
|
||||
<div class="flex flex-col h-full overflow-auto hide-scrollbar">
|
||||
<template v-if="!showSkeleton && activities.length > 0">
|
||||
<div
|
||||
v-for="activity in activities"
|
||||
:key="activity.name"
|
||||
@click="goToTicket(activity)"
|
||||
class="flex items-center gap-3 px-2 py-3 text-sm cursor-pointer hover:bg-surface-sidebar rounded"
|
||||
>
|
||||
<component
|
||||
:is="iconFor(activity.activity_type)"
|
||||
class="size-4 flex-shrink-0 text-ink-gray-5"
|
||||
/>
|
||||
<div class="flex flex-col min-w-0 grow gap-1">
|
||||
<span class="truncate text-ink-gray-8">
|
||||
{{ activity.subject || __("Untitled") }}
|
||||
</span>
|
||||
<span class="truncate text-ink-gray-5">
|
||||
#{{ activity.name }} · {{ label(activity) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-ink-gray-4 whitespace-nowrap tabular-nums">
|
||||
{{ activity.timestamp }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="showSkeleton" class="flex flex-col">
|
||||
<div v-for="i in 6" :key="i" class="flex items-center gap-3 p-2 py-3">
|
||||
<div class="size-4 rounded-sm bg-surface-gray-1 flex-shrink-0" />
|
||||
<div class="flex flex-col gap-1 grow">
|
||||
<div class="h-4 w-2/3 rounded-sm bg-surface-gray-1" />
|
||||
<div class="h-3 w-1/3 rounded-sm bg-surface-gray-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
:title="__('No recent activity')"
|
||||
:description="__('Tickets you act on will show up here')"
|
||||
text="md"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!showSkeleton && activities.length > 0"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-8"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'linear-gradient(to top, var(--surface-base), transparent)',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import EmptyState from "@/components/EmptyState.vue";
|
||||
import { __ } from "@/translation";
|
||||
import { createResource, Tooltip } from "frappe-ui";
|
||||
import { computed, onMounted, ref, type PropType } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import ActivityIcon from "~icons/lucide/activity";
|
||||
import ReplyIcon from "~icons/lucide/corner-up-left";
|
||||
import EyeIcon from "~icons/lucide/eye";
|
||||
import MessageSquareIcon from "~icons/lucide/message-square";
|
||||
|
||||
interface RecentActivity {
|
||||
name: string;
|
||||
subject: string;
|
||||
activity_type: "replied" | "commented" | "status" | "viewed";
|
||||
text: string | null;
|
||||
timestamp: string;
|
||||
creation: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array as PropType<RecentActivity[]>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
replied: __("Replied"),
|
||||
commented: __("Commented"),
|
||||
viewed: __("Viewed"),
|
||||
};
|
||||
|
||||
const icons = {
|
||||
replied: ReplyIcon,
|
||||
commented: MessageSquareIcon,
|
||||
status: ActivityIcon,
|
||||
viewed: EyeIcon,
|
||||
};
|
||||
|
||||
const hasLoadedOnce = ref(false);
|
||||
|
||||
const recentActivityResource = createResource({
|
||||
url: "helpdesk.api.agent_home.agent_home.get_recent_activity",
|
||||
onSuccess() {
|
||||
hasLoadedOnce.value = true;
|
||||
},
|
||||
});
|
||||
|
||||
const activities = computed<RecentActivity[]>(() =>
|
||||
recentActivityResource.fetched ? recentActivityResource.data : props.data
|
||||
);
|
||||
|
||||
const showSkeleton = computed(
|
||||
() => recentActivityResource.loading && !hasLoadedOnce.value
|
||||
);
|
||||
|
||||
function iconFor(type: string) {
|
||||
return icons[type as keyof typeof icons] || ActivityIcon;
|
||||
}
|
||||
|
||||
function label(activity: RecentActivity) {
|
||||
return activity.activity_type === "status"
|
||||
? activity.text
|
||||
: typeLabels[activity.activity_type];
|
||||
}
|
||||
|
||||
const goToTicket = (activity: RecentActivity) => {
|
||||
router.push({
|
||||
name: "TicketAgent",
|
||||
params: { ticketId: String(activity.name) },
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!Array.isArray(props.data)) {
|
||||
recentActivityResource.fetch();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
Submodule frappe-ui updated: e9a58979bd...a3d141c63b
@@ -571,6 +571,118 @@ def _get_pending_response_tickets(limit=10):
|
||||
return tickets, total_count
|
||||
|
||||
|
||||
# Most significant action wins when collapsing a ticket's events to one row.
|
||||
ACTIVITY_PRIORITY = {"replied": 4, "commented": 3, "status": 2, "viewed": 1}
|
||||
RECENT_ACTIVITY_LIMIT = 10
|
||||
OVER_FETCH_PER_SOURCE = 30
|
||||
|
||||
# (doctype, user_field, ticket_field, activity_type, extra_filters)
|
||||
ACTIVITY_SOURCES = [
|
||||
(
|
||||
"Communication",
|
||||
"owner",
|
||||
"reference_name",
|
||||
"replied",
|
||||
{"reference_doctype": "HD Ticket", "sent_or_received": "Sent"},
|
||||
),
|
||||
("HD Ticket Comment", "commented_by", "reference_ticket", "commented", {}),
|
||||
("HD Ticket Activity", "owner", "ticket", "status", {}),
|
||||
(
|
||||
"View Log",
|
||||
"viewed_by",
|
||||
"reference_name",
|
||||
"viewed",
|
||||
{"reference_doctype": "HD Ticket"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _activity_events(
|
||||
doctype: str, filters: dict, ticket_field: str, activity_type: str
|
||||
) -> list[dict]:
|
||||
fields = [f"{ticket_field} as ticket", "creation"]
|
||||
if activity_type == "status":
|
||||
fields.append("action")
|
||||
rows = frappe.get_all(
|
||||
doctype,
|
||||
filters={
|
||||
**filters,
|
||||
"creation": [">=", frappe.utils.add_days(frappe.utils.nowdate(), -7)],
|
||||
},
|
||||
fields=fields,
|
||||
order_by="creation desc",
|
||||
limit=OVER_FETCH_PER_SOURCE,
|
||||
)
|
||||
events = []
|
||||
for row in rows:
|
||||
if not row.get("ticket"):
|
||||
continue
|
||||
text = row.action if activity_type == "status" else None
|
||||
events.append(
|
||||
{
|
||||
"ticket": row.ticket,
|
||||
"type": activity_type,
|
||||
"text": text,
|
||||
"creation": row.creation,
|
||||
}
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _collapse_events(events: list[dict]) -> dict[str, dict]:
|
||||
"""One event per ticket: highest priority, ties broken by recency."""
|
||||
best: dict[str, dict] = {}
|
||||
for event in sorted(
|
||||
events, key=lambda e: (ACTIVITY_PRIORITY[e["type"]], e["creation"])
|
||||
):
|
||||
best[event["ticket"]] = event
|
||||
return best
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@agent_only
|
||||
def get_recent_activity() -> list[dict]:
|
||||
"""Tickets the current agent recently acted on, one row per ticket showing
|
||||
the most significant action (replied > commented > status change > viewed)."""
|
||||
user = frappe.session.user
|
||||
|
||||
events = []
|
||||
for doctype, user_field, ticket_field, activity_type, extra in ACTIVITY_SOURCES:
|
||||
events += _activity_events(
|
||||
doctype, {**extra, user_field: user}, ticket_field, activity_type
|
||||
)
|
||||
|
||||
best = _collapse_events(events)
|
||||
if not best:
|
||||
return []
|
||||
|
||||
# get_list so tickets the agent can no longer access drop out.
|
||||
accessible = frappe.get_list(
|
||||
"HD Ticket",
|
||||
filters=[["name", "in", list(best.keys())]],
|
||||
fields=["name", "subject"],
|
||||
)
|
||||
subjects = {t["name"]: t["subject"] for t in accessible}
|
||||
|
||||
activities = []
|
||||
for ticket, event in best.items():
|
||||
if ticket not in subjects:
|
||||
continue
|
||||
activities.append(
|
||||
{
|
||||
"name": ticket,
|
||||
"subject": subjects[ticket],
|
||||
"activity_type": event["type"],
|
||||
"text": event["text"],
|
||||
"timestamp": format_time_difference(event["creation"]),
|
||||
"creation": event["creation"],
|
||||
}
|
||||
)
|
||||
|
||||
activities.sort(key=lambda a: a["creation"], reverse=True)
|
||||
return activities[:RECENT_ACTIVITY_LIMIT]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@agent_only
|
||||
def get_pending_tickets(ticket_type: str = "upcoming_sla"):
|
||||
|
||||
@@ -7,6 +7,7 @@ import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
from helpdesk.api.agent_home.agent_home import (
|
||||
_collapse_events,
|
||||
get_agent_tickets,
|
||||
get_avg_first_response_time,
|
||||
get_avg_resolution_time,
|
||||
@@ -14,6 +15,7 @@ from helpdesk.api.agent_home.agent_home import (
|
||||
get_dashboard,
|
||||
get_default_agent_dashboard,
|
||||
get_pending_tickets,
|
||||
get_recent_activity,
|
||||
get_recent_feedback,
|
||||
)
|
||||
from helpdesk.test_utils import make_agent, make_sla, make_ticket
|
||||
@@ -596,3 +598,56 @@ class TestAgentHome(FrappeTestCase):
|
||||
for t in result["tickets"]:
|
||||
self.assertEqual(t["reason"]["type"], "pending")
|
||||
self.assertIn("Pending for", t["reason"]["text"])
|
||||
|
||||
def test_collapse_events_priority_and_recency(self):
|
||||
"""One row per ticket: highest-priority action wins, ties broken by
|
||||
recency, and a viewed-only ticket still survives."""
|
||||
# replied (older) beats a later view on the same ticket
|
||||
events = [
|
||||
{"ticket": "T1", "type": "viewed", "creation": 10},
|
||||
{"ticket": "T1", "type": "replied", "creation": 5},
|
||||
]
|
||||
self.assertEqual(_collapse_events(events)["T1"]["type"], "replied")
|
||||
|
||||
# same type -> most recent wins
|
||||
events = [
|
||||
{"ticket": "T2", "type": "viewed", "creation": 3},
|
||||
{"ticket": "T2", "type": "viewed", "creation": 9},
|
||||
]
|
||||
self.assertEqual(_collapse_events(events)["T2"]["creation"], 9)
|
||||
|
||||
# a ticket the agent only viewed still shows up
|
||||
events = [{"ticket": "T3", "type": "viewed", "creation": 1}]
|
||||
self.assertEqual(_collapse_events(events)["T3"]["type"], "viewed")
|
||||
|
||||
def test_get_recent_activity_collapses_and_labels(self):
|
||||
"""A ticket the agent both viewed and commented on appears once, as the
|
||||
higher-priority action (commented > viewed)."""
|
||||
ticket = self._create_ticket(subject="Recent Activity Ticket")
|
||||
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "View Log",
|
||||
"reference_doctype": "HD Ticket",
|
||||
"reference_name": ticket.name,
|
||||
"viewed_by": agent_email,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "HD Ticket Comment",
|
||||
"reference_ticket": ticket.name,
|
||||
"commented_by": agent_email,
|
||||
"content": "Looking into this",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
rows = [r for r in get_recent_activity() if r["name"] == ticket.name]
|
||||
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["activity_type"], "commented")
|
||||
self.assertEqual(rows[0]["subject"], "Recent Activity Ticket")
|
||||
# Only status rows carry server-side text (the raw action); the label for
|
||||
# other types is applied on the frontend.
|
||||
self.assertIsNone(rows[0]["text"])
|
||||
|
||||
@@ -4,7 +4,7 @@ from pypika.functions import Count
|
||||
|
||||
|
||||
def get_default_agent_dashboard():
|
||||
return '[{"chart":"avg_resolution_time","layout":{"x":33,"y":0,"w":17,"h":10,"minW":16,"minH":10,"maxH":11}},{"chart":"avg_first_response_time","layout":{"x":16,"y":0,"w":17,"h":10,"minW":16,"minH":10,"maxH":11}},{"chart":"agent_tickets","layout":{"x":0,"y":0,"w":16,"h":10,"minW":16,"minH":10,"maxH":11}},{"chart":"pending_tickets","layout":{"x":0,"y":10,"w":50,"h":32,"minW":25,"minH":32,"maxH":32}},{"chart":"recent_feedback","layout":{"x":0,"y":66,"w":50,"h":21,"minW":50,"minH":21,"maxH":21}},{"chart":"avg_time_metrics","layout":{"x":0,"y":42,"w":50,"h":24,"minW":18,"minH":24,"maxH":44}}]'
|
||||
return '[{"chart":"avg_resolution_time","layout":{"x":33,"y":0,"w":17,"h":10,"minW":16,"minH":10,"maxH":11}},{"chart":"avg_first_response_time","layout":{"x":16,"y":0,"w":17,"h":10,"minW":16,"minH":10,"maxH":11}},{"chart":"agent_tickets","layout":{"x":0,"y":0,"w":16,"h":10,"minW":16,"minH":10,"maxH":11}},{"chart":"pending_tickets","layout":{"x":0,"y":10,"w":50,"h":32,"minW":25,"minH":32,"maxH":32}},{"chart":"recent_activity","layout":{"x":0,"y":42,"w":17,"h":29,"minW":12,"minH":20,"maxH":44}},{"chart":"avg_time_metrics","layout":{"x":17,"y":42,"w":33,"h":29,"minW":18,"minH":24,"maxH":44}},{"chart":"recent_feedback","layout":{"x":0,"y":71,"w":50,"h":21,"minW":50,"minH":21,"maxH":21}}]'
|
||||
|
||||
|
||||
def calculate_percentage_change(
|
||||
|
||||
Reference in New Issue
Block a user