feat: recent activity

This commit is contained in:
Ritvik Sardana
2026-07-22 17:33:07 +05:30
parent 77981e1f01
commit fd6e34b0c4
9 changed files with 340 additions and 6 deletions

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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);
});

View File

@@ -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 {

View 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>

View File

@@ -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"):

View File

@@ -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"])

View File

@@ -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(