Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ helpdesk/public/frontend
helpdesk/public/node_modules
helpdesk/www/helpdesk/index.html
desk/components.d.ts
desk/stats.html
desk/stats.html
CLAUDE.md
1 change: 1 addition & 0 deletions desk/src/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { default as DescendingIcon } from "./DescendingIcon.vue";
export { default as DetailsIcon } from "./DetailsIcon.vue";
export { default as DotIcon } from "./DotIcon.vue";
export { default as DragIcon } from "./DragIcon.vue";
export { default as DurationIcon } from "./DurationIcon.vue";
export { default as EditIcon } from "./EditIcon.vue";
export { default as EmailAtIcon } from "./EmailAtIcon.vue";
export { default as EmailIcon } from "./EmailIcon.vue";
Expand Down
11 changes: 10 additions & 1 deletion desk/src/components/ticket-agent/TicketActivityPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
class="[&_[role='tab']]:px-0 [&_[role='tablist']]:px-5 [&_[role='tablist']]:gap-7.5 [&_[role='tablist']]:flex-shrink-0"
>
<template #tab-panel="{ tab }">
<TimeLogSection v-if="tab.name === 'timelog'" />
<TicketAgentActivities
v-if="Boolean(activities.data)"
v-else-if="Boolean(activities.data)"
ref="ticketAgentActivitiesRef"
:activities="filterActivities(tab.name as TicketTab)"
:title="tab.label"
Expand Down Expand Up @@ -53,9 +54,11 @@
import {
ActivityIcon,
CommentIcon,
DurationIcon,
EmailIcon,
PhoneIcon,
} from "@/components/icons";
import TimeLogSection from "./TimeLogSection.vue";
import { useActiveTabManager } from "@/composables/useActiveTabManager";
import { useTelephonyStore } from "@/stores/telephony";
import {
Expand Down Expand Up @@ -101,6 +104,12 @@ const tabs: ComputedRef<TabObject[]> = computed(() => {
},
];

_tabs.push({
name: "timelog",
label: "Time Logs",
icon: DurationIcon,
});

if (isCallingEnabled.value) {
_tabs.push({
name: "call",
Expand Down
224 changes: 224 additions & 0 deletions desk/src/components/ticket-agent/TimeLogSection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<template>
<div class="flex flex-col h-full overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-3 border-b">
<div class="flex items-center gap-2">
<span class="text-base font-medium text-ink-gray-9">
{{ __("Time Logs") }}
</span>
<span
v-if="totalHours"
class="text-sm text-ink-gray-5"
>
({{ totalHours }}h total)
</span>
</div>
<Button
variant="subtle"
size="sm"
:label="showForm ? __('Cancel') : __('Add Entry')"
@click="toggleForm"
>
<template v-if="!showForm" #prefix>
<FeatherIcon name="plus" class="size-3.5" />
</template>
</Button>
</div>

<!-- Add Form -->
<div v-if="showForm" class="px-5 py-3 border-b bg-surface-gray-2">
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<label class="block text-xs text-ink-gray-6 mb-1.5">{{ __("Date") }}</label>
<input
type="date"
v-model="form.date"
class="w-full rounded border border-outline-gray-2 bg-surface-white px-2 py-1.5 text-sm text-ink-gray-8"
/>
</div>
<div>
<label class="block text-xs text-ink-gray-6 mb-1.5">{{ __("Hours") }}</label>
<input
type="number"
step="0.25"
min="0"
v-model="form.hours"
:placeholder="__('e.g. 1.5')"
class="w-full rounded border border-outline-gray-2 bg-surface-white px-2 py-1.5 text-sm text-ink-gray-8"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<label class="block text-xs text-ink-gray-6 mb-1.5">{{ __("Start Time") }}</label>
<input
type="time"
v-model="form.start_time"
class="w-full rounded border border-outline-gray-2 bg-surface-white px-2 py-1.5 text-sm text-ink-gray-8"
/>
</div>
<div>
<label class="block text-xs text-ink-gray-6 mb-1.5">{{ __("End Time") }}</label>
<input
type="time"
v-model="form.end_time"
class="w-full rounded border border-outline-gray-2 bg-surface-white px-2 py-1.5 text-sm text-ink-gray-8"
/>
</div>
</div>
<div class="mb-3">
<label class="block text-xs text-ink-gray-6 mb-1.5">{{ __("Note") }}</label>
<input
type="text"
v-model="form.note"
:placeholder="__('What did you work on?')"
class="w-full rounded border border-outline-gray-2 bg-surface-white px-2 py-1.5 text-sm text-ink-gray-8"
/>
</div>
<Button
variant="solid"
size="sm"
:label="__('Save')"
:loading="saving"
@click="handleAdd"
/>
</div>

<!-- Time Logs List -->
<div class="flex-1 overflow-y-auto">
<div v-if="timeLogs.length === 0" class="flex items-center justify-center py-20">
<p class="text-sm text-ink-gray-5">{{ __("No time logs yet") }}</p>
</div>
<div v-else>
<!-- Table Header -->
<div
class="grid gap-2 px-5 py-2 text-xs font-medium text-ink-gray-5 border-b"
:style="{ gridTemplateColumns: '90px 60px 1fr 28px' }"
>
<span>{{ __("Date") }}</span>
<span class="text-right">{{ __("Hours") }}</span>
<span>{{ __("Note") }}</span>
<span></span>
</div>
<!-- Rows -->
<div
v-for="log in timeLogs"
:key="log.name"
class="grid gap-2 px-5 py-2.5 items-center border-b text-sm hover:bg-surface-gray-2"
:style="{ gridTemplateColumns: '90px 60px 1fr 28px' }"
>
<span class="text-ink-gray-8">{{ formatDate(log.date) }}</span>
<span class="text-right font-medium text-ink-gray-9">
{{ log.hours || '-' }}
</span>
<div class="truncate text-ink-gray-6">
<span v-if="log.note">{{ log.note }}</span>
<span v-else-if="log.start_time && log.end_time" class="text-ink-gray-4">
{{ log.start_time }} - {{ log.end_time }}
</span>
</div>
<button
class="text-ink-gray-4 hover:text-ink-gray-8 rounded p-0.5"
@click="handleDelete(log.name)"
>
<FeatherIcon name="x" class="size-3.5" />
</button>
</div>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { TicketSymbol } from "@/types";
import { Button, call, toast } from "frappe-ui";
import { computed, inject, reactive, ref } from "vue";

const ticket = inject(TicketSymbol);

const showForm = ref(false);
const saving = ref(false);

const form = reactive({
date: "",
start_time: "",
end_time: "",
hours: "",
note: "",
});

function toggleForm() {
showForm.value = !showForm.value;
if (showForm.value) {
form.date = new Date().toISOString().split("T")[0];
form.start_time = "";
form.end_time = "";
form.hours = "";
form.note = "";
}
}

const timeLogs = computed(() => {
return (ticket.value?.doc?.time_logs || []).slice().sort(
(a, b) => new Date(b.date || b.creation).getTime() - new Date(a.date || a.creation).getTime()
);
});

const totalHours = computed(() => {
return ticket.value?.doc?.total_hours || 0;
});

function formatDate(date: string) {
if (!date) return "";
const d = new Date(date);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}

async function handleAdd() {
if (!form.date) {
toast.error(__("Date is required"));
return;
}
const hours = form.hours ? parseFloat(form.hours) : 0;
if (!form.start_time && !form.end_time && !hours) {
toast.error(__("Enter hours or start/end times"));
return;
}
saving.value = true;
try {
await call(
"helpdesk.helpdesk.doctype.hd_ticket.api.add_time_log",
{
ticket_id: String(ticket.value.doc.name),
date: form.date,
start_time: form.start_time || undefined,
end_time: form.end_time || undefined,
hours: hours,
note: form.note || undefined,
}
);
ticket.value.reload();
showForm.value = false;
toast.success(__("Time log added"));
} catch (e: any) {
toast.error(e.messages?.[0] || __("Failed to add time log"));
}
saving.value = false;
}

async function handleDelete(rowName: string) {
try {
await call(
"helpdesk.helpdesk.doctype.hd_ticket.api.delete_time_log",
{
ticket_id: String(ticket.value.doc.name),
row_name: String(rowName),
}
);
ticket.value.reload();
toast.success(__("Time log removed"));
} catch (e: any) {
toast.error(e.messages?.[0] || __("Failed to remove time log"));
}
}
</script>
2 changes: 1 addition & 1 deletion desk/src/pages/dashboard/Dashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@

<!-- Number Cards -->
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-4"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-4"
v-if="!numberCards.loading"
>
<Tooltip
Expand Down
2 changes: 1 addition & 1 deletion desk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ export interface EmailAccount {
default_incoming?: boolean;
}

export type TicketTab = "activity" | "email" | "comment" | "details" | "call";
export type TicketTab = "activity" | "email" | "comment" | "details" | "call" | "timelog";

export interface TabObject {
name: TicketTab;
Expand Down
4 changes: 4 additions & 0 deletions desk/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ export default defineConfig(async ({ mode }) => {
alias: {
"@": path.resolve(__dirname, "src"),
"tailwind.config.js": path.resolve(__dirname, "tailwind.config.js"),
"../../../frappe": path.resolve(
process.env.HOME || "/home/codespace",
"frappe-bench/apps/frappe"
),
...localFrappeUIAliases,
},
},
Expand Down
4 changes: 4 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ services:
- --skip-innodb-read-only-compressed # Temporary fix for MariaDB 10.6
environment:
MYSQL_ROOT_PASSWORD: 123
ports:
- 3306:3306
volumes:
- mariadb-data:/var/lib/mysql

redis:
image: redis:alpine
ports:
- 6379:6379

frappe:
image: frappe/bench:latest
Expand Down
25 changes: 25 additions & 0 deletions helpdesk/api/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ def get_number_card_data(self):
self.get_avg_first_response_time(),
self.get_avg_resolution_time(),
self.get_avg_feedback_score(),
self.get_stuck_ticket_count(),
]

def get_ticket_count(self):
Expand Down Expand Up @@ -236,6 +237,30 @@ def get_avg_feedback_score(self):
"tooltip": _("Avg. feedback rating for the tickets resolved"),
}

def get_stuck_ticket_count(self):
"""Count currently stuck tickets (not period-based — shows live count)."""
conds = [self.ticket.is_stuck == 1]
if self.combined_cond:
conds.append(self.combined_cond)

combined = reduce(operator.and_, conds)
query = (
frappe.qb.from_(self.ticket)
.select(Count(self.ticket.name).as_("count"))
.where(combined)
)
result = query.run(as_dict=True)
count = result[0].count if result else 0

return {
"title": _("Stuck Tickets"),
"value": count,
"delta": None,
"deltaSuffix": "",
"negativeIsBetter": True,
"tooltip": _("Tickets flagged as stuck (open > 3 days, paused > 3 days, or 4+ status changes)"),
}

def get_trend_data(self):
return [
self.get_ticket_trend_data(),
Expand Down
Loading