mirror of
https://github.com/hanzoai/helpdesk.git
synced 2026-10-12 16:56:02 +00:00
Merge branch 'develop' into fix/bump-frappe-ui
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<Select
|
||||
:model-value="currentStatus"
|
||||
:model-value="agentStatusStore.myStatus"
|
||||
:options="dropdownOptions"
|
||||
:placeholder="__('Set status')"
|
||||
variant="subtle"
|
||||
@update:model-value="setStatus"
|
||||
@update:model-value="agentStatusStore.setMyStatus"
|
||||
>
|
||||
<template #item-prefix="{ option }">
|
||||
<div
|
||||
@@ -16,17 +16,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAvailability } from "@/composables/useAvailability";
|
||||
import { useAgentStatusStore } from "@/stores/agentStatus";
|
||||
import { __ } from "@/translation";
|
||||
import { Select } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
|
||||
const { currentStatus, options, setStatus } = useAvailability();
|
||||
const agentStatusStore = useAgentStatusStore();
|
||||
|
||||
const dropdownOptions = computed(() =>
|
||||
options.value.map((statusOption: string) => ({
|
||||
agentStatusStore.statusOptions.map((statusOption: string) => ({
|
||||
label: __(statusOption),
|
||||
value: statusOption,
|
||||
}))
|
||||
|
||||
@@ -15,11 +15,15 @@
|
||||
<div class="flex items-center size-4">
|
||||
<span
|
||||
class="size-2.5 mx-auto shrink-0 rounded-full"
|
||||
:class="agentStatusStore.statusColor(currentStatus)"
|
||||
:class="agentStatusStore.statusColor(agentStatusStore.myStatus)"
|
||||
/>
|
||||
</div>
|
||||
<span class="truncate">
|
||||
{{ currentStatus ? __(currentStatus) : __("Set status") }}
|
||||
{{
|
||||
agentStatusStore.myStatus
|
||||
? __(agentStatusStore.myStatus)
|
||||
: __("Set status")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -29,7 +33,7 @@
|
||||
class="flex flex-col mx-3 p-1.5 rounded-lg border border-outline-gray-1 bg-surface-base shadow-xl min-w-[140px]"
|
||||
>
|
||||
<button
|
||||
v-for="option in options"
|
||||
v-for="option in agentStatusStore.statusOptions"
|
||||
:key="option"
|
||||
class="flex items-center gap-2 rounded p-1.5 text-sm text-ink-gray-8 hover:bg-surface-gray-2"
|
||||
@click="selectStatus(option, close)"
|
||||
@@ -47,15 +51,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Popover } from "frappe-ui";
|
||||
import { useAvailability } from "@/composables/useAvailability";
|
||||
import { useAgentStatusStore } from "@/stores/agentStatus";
|
||||
import { __ } from "@/translation";
|
||||
|
||||
const { currentStatus, options, setStatus } = useAvailability();
|
||||
const agentStatusStore = useAgentStatusStore();
|
||||
|
||||
function selectStatus(status: string, close: () => void) {
|
||||
setStatus(status);
|
||||
agentStatusStore.setMyStatus(status);
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -22,9 +22,11 @@
|
||||
:label="fullName"
|
||||
/>
|
||||
<div
|
||||
v-if="currentStatus"
|
||||
v-if="agentStatusStore.myStatus"
|
||||
class="absolute bottom-0.5 right-0.5 size-3.5 rounded-full outline outline-white outline-4"
|
||||
:class="agentStatusStore.statusColor(currentStatus)"
|
||||
:class="
|
||||
agentStatusStore.statusColor(agentStatusStore.myStatus)
|
||||
"
|
||||
/>
|
||||
<Tooltip
|
||||
:hoverDelay="0"
|
||||
@@ -179,11 +181,9 @@ const emit = defineEmits(["updateStep"]);
|
||||
|
||||
import AvailabilityMenu from "@/components/AvailabilityMenu.vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { useAvailability } from "@/composables/useAvailability";
|
||||
import { useAgentStatusStore } from "@/stores/agentStatus";
|
||||
import ChangePasswordModal from "./components/ChangePasswordModal.vue";
|
||||
|
||||
const { currentStatus } = useAvailability();
|
||||
const agentStatusStore = useAgentStatusStore();
|
||||
const showChangePasswordModal = ref(false);
|
||||
|
||||
|
||||
@@ -162,7 +162,6 @@ import {
|
||||
LocalAssignee,
|
||||
TicketSymbol,
|
||||
} from "@/types";
|
||||
import type { HDAgent } from "@/types/doctypes";
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import {
|
||||
Button,
|
||||
@@ -199,7 +198,7 @@ const activities = inject(ActivitiesSymbol)!;
|
||||
const { getUser } = useUserStore();
|
||||
const currentUser = computed(() => getUser("")); // empty string returns current user
|
||||
const agentStatusStore = useAgentStatusStore();
|
||||
const currentAgentName = (window as any).agent as string | null;
|
||||
const currentAgentName = window.agent;
|
||||
|
||||
const searchText = ref("");
|
||||
const highlightedIndex = ref(0);
|
||||
@@ -259,30 +258,11 @@ const agentResource = createListResource({
|
||||
"availability",
|
||||
"availability_changed_on",
|
||||
],
|
||||
filters: { is_active: true, name: ["!=", "[email protected]"] },
|
||||
filters: { is_active: true },
|
||||
pageLength: 20,
|
||||
auto: true,
|
||||
});
|
||||
|
||||
// Fetch current agent separately to guarantee they appear in the list
|
||||
const currentAgentResource = currentAgentName
|
||||
? createResource({
|
||||
url: "frappe.client.get",
|
||||
params: {
|
||||
doctype: "HD Agent",
|
||||
name: currentAgentName,
|
||||
fields: [
|
||||
"name",
|
||||
"agent_name",
|
||||
"user_image",
|
||||
"availability",
|
||||
"availability_changed_on",
|
||||
],
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
const debouncedSearch = useDebounceFn((text: string) => {
|
||||
const filters: Record<string, any> = { is_active: true };
|
||||
if (text) {
|
||||
@@ -315,16 +295,16 @@ const agentOptions = computed<AgentOption[]>(() => {
|
||||
const agents: AgentOption[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Include current agent only when not searching
|
||||
if (!searchText.value && currentAgentResource?.data) {
|
||||
const a = currentAgentResource.data;
|
||||
// Include current agent only when not searching. Built from the session user
|
||||
// and the store's live status (seeded from auth.get_user) — no extra fetch.
|
||||
if (!searchText.value && currentAgentName) {
|
||||
agents.push({
|
||||
value: a.name,
|
||||
label: a.agent_name || getUser(a.name).full_name,
|
||||
image: a.user_image || getUser(a.name).user_image,
|
||||
...liveAvailability(a),
|
||||
value: currentAgentName,
|
||||
label: currentUser.value.full_name || currentAgentName,
|
||||
image: currentUser.value.user_image || "",
|
||||
...liveAvailability({ name: currentAgentName }),
|
||||
});
|
||||
seen.add(a.name);
|
||||
seen.add(currentAgentName);
|
||||
}
|
||||
|
||||
if (agentResource.data) {
|
||||
@@ -555,22 +535,22 @@ const removeAssigneesResource = createResource({
|
||||
// Toast a warning for each newly-added agent who isn't currently active.
|
||||
// Returns true if any warning was shown so the caller can defer the success toast.
|
||||
function warnUnavailableAgents(addedNames: string[]): boolean {
|
||||
const agents = (agentResource.data as HDAgent[]) ?? [];
|
||||
let hasUnavailable = false;
|
||||
for (const agent of agents) {
|
||||
if (!addedNames.includes(agent.name)) continue;
|
||||
// Iterate the rendered options (not just the fetched page) so agents picked
|
||||
// via search — who aren't in the default list — are still checked.
|
||||
for (const agent of sortedAgentOptions.value) {
|
||||
if (!addedNames.includes(agent.value)) continue;
|
||||
// No point warning agents about their own status when assigning themselves.
|
||||
if (agent.name === currentAgentName) continue;
|
||||
if (agent.value === currentAgentName) continue;
|
||||
const category = agentStatusStore.getStatus(
|
||||
agent.availability || ""
|
||||
)?.category;
|
||||
if (category !== "Away" && category !== "Unavailable") continue;
|
||||
|
||||
const name = agent.agent_name || agent.name;
|
||||
toast.warning(
|
||||
category === "Unavailable"
|
||||
? __("{0} is currently unavailable.", name)
|
||||
: __("{0} is currently away.", name)
|
||||
? __("{0} is currently unavailable.", agent.label)
|
||||
: __("{0} is currently away.", agent.label)
|
||||
);
|
||||
hasUnavailable = true;
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { createResource, toast } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
import { __ } from "@/translation";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const availability = createResource({
|
||||
url: "helpdesk.api.agent.get_my_availability",
|
||||
});
|
||||
|
||||
const setAvailability = createResource({
|
||||
url: "helpdesk.api.agent.set_my_availability",
|
||||
onSuccess: () => { availability.reload();
|
||||
toast.success(__("Status updated successfully."));
|
||||
},
|
||||
});
|
||||
|
||||
export function useAvailability() {
|
||||
const authStore = useAuthStore();
|
||||
if (authStore.isAgent && !availability.fetched) {
|
||||
availability.fetch();
|
||||
}
|
||||
|
||||
const currentStatus = computed(() => availability.data?.availability || "");
|
||||
const options = computed(() => availability.data?.options || []);
|
||||
|
||||
function setStatus(status: string) {
|
||||
if (status === currentStatus.value) return;
|
||||
setAvailability.submit({ availability: status });
|
||||
}
|
||||
|
||||
return { currentStatus, options, setStatus };
|
||||
}
|
||||
+1
-2
@@ -27,8 +27,7 @@
|
||||
html,
|
||||
body {
|
||||
color: var(--ink-gray-9);
|
||||
background-color: var(--surface-base);
|
||||
overscroll-behavior: none;
|
||||
background-color: var(--surface-white);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { globalStore } from "@/stores/globalStore";
|
||||
import { __ } from "@/translation";
|
||||
import { HDAgentStatus } from "@/types/doctypes";
|
||||
import { createListResource } from "frappe-ui";
|
||||
import { createListResource, createResource, toast } from "frappe-ui";
|
||||
import { defineStore } from "pinia";
|
||||
import { reactive } from "vue";
|
||||
import { computed, reactive, watch } from "vue";
|
||||
|
||||
interface LiveAvailability {
|
||||
availability: string;
|
||||
@@ -35,6 +37,9 @@ const dotColorMap: Record<string, string> = {
|
||||
const defaultColor = "bg-surface-gray-8";
|
||||
|
||||
export const useAgentStatusStore = defineStore("agentStatus", () => {
|
||||
// Set only for sessions that have an HD Agent record; null otherwise.
|
||||
const myAgentName = window.agent;
|
||||
|
||||
const statuses = createListResource({
|
||||
doctype: "HD Agent Status",
|
||||
cache: ["HD Agent Status", "list"],
|
||||
@@ -44,19 +49,69 @@ export const useAgentStatusStore = defineStore("agentStatus", () => {
|
||||
auto: true,
|
||||
});
|
||||
|
||||
// Live availability pushed over the socket when an agent changes their
|
||||
// status (helpdesk.api.agent.set_my_availability). Keyed by HD Agent name,
|
||||
// it overrides the value fetched when a list first loaded.
|
||||
// Live availability keyed by HD Agent name. Seeded by fetches and kept current
|
||||
// over the socket — set_my_availability broadcasts to every client, so this is
|
||||
// the single source of truth for "who is what right now", including ourselves.
|
||||
const liveStatuses = reactive<Record<string, LiveAvailability>>({});
|
||||
|
||||
function applyLive(agent: string, availability?: string, changedOn?: string) {
|
||||
if (!agent || !availability) return;
|
||||
liveStatuses[agent] = { availability, changedOn: changedOn ?? "" };
|
||||
}
|
||||
|
||||
const { $socket } = globalStore();
|
||||
$socket.on("agent_availability_updated", (data: AvailabilityEvent) => {
|
||||
liveStatuses[data.agent] = {
|
||||
availability: data.availability,
|
||||
changedOn: data.availability_changed_on,
|
||||
};
|
||||
$socket.on("agent_availability_updated", (data: AvailabilityEvent) =>
|
||||
applyLive(data.agent, data.availability, data.availability_changed_on)
|
||||
);
|
||||
|
||||
// Seed our own status from the session payload (auth.get_user already resolves
|
||||
// the agent), then let the socket and the optimistic write keep it current — no
|
||||
// dedicated fetch and no reload.
|
||||
const auth = useAuthStore();
|
||||
watch(
|
||||
() => auth.availability,
|
||||
(availability) =>
|
||||
applyLive(myAgentName ?? "", availability, auth.availabilityChangedOn),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const setMyAvailability = createResource({
|
||||
url: "helpdesk.api.agent.set_my_availability",
|
||||
onSuccess: () => toast.success(__("Status updated successfully.")),
|
||||
onError: () => toast.error(__("Could not update status.")),
|
||||
});
|
||||
|
||||
const myStatus = computed(
|
||||
() => (myAgentName && liveStatuses[myAgentName]?.availability) || ""
|
||||
);
|
||||
|
||||
// Selectable statuses, derived from the same list the presence dots use so the
|
||||
// menu and the dots never disagree and we avoid a second server round-trip.
|
||||
const statusOptions = computed<string[]>(() =>
|
||||
(statuses.data ?? [])
|
||||
.filter((s: HDAgentStatus) => s.enable)
|
||||
.map((s: HDAgentStatus) => s.agent_status)
|
||||
);
|
||||
|
||||
function setMyStatus(status: string) {
|
||||
if (!myAgentName || !status || status === myStatus.value) return;
|
||||
// Optimistic: update immediately, then roll back if the server rejects it.
|
||||
// changedOn is left empty — it is never rendered for our own dot and the
|
||||
// socket echo overwrites it with the authoritative server timestamp.
|
||||
const previous = liveStatuses[myAgentName];
|
||||
applyLive(myAgentName, status);
|
||||
setMyAvailability.submit(
|
||||
{ availability: status },
|
||||
{
|
||||
// Roll back the optimistic write on failure.
|
||||
onError: () => {
|
||||
if (previous) liveStatuses[myAgentName] = previous;
|
||||
else delete liveStatuses[myAgentName];
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getStatus(name: string): HDAgentStatus | undefined {
|
||||
return statuses.data?.find((s: HDAgentStatus) => s.agent_status === name);
|
||||
}
|
||||
@@ -66,5 +121,13 @@ export const useAgentStatusStore = defineStore("agentStatus", () => {
|
||||
return (color && dotColorMap[color]) || defaultColor;
|
||||
}
|
||||
|
||||
return { statuses, liveStatuses, getStatus, statusColor };
|
||||
return {
|
||||
statuses,
|
||||
liveStatuses,
|
||||
myStatus,
|
||||
statusOptions,
|
||||
setMyStatus,
|
||||
getStatus,
|
||||
statusColor,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -44,6 +44,12 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
);
|
||||
const userName: ComputedRef<string> = computed(() => user__.value.user_name);
|
||||
const username: ComputedRef<string> = computed(() => user__.value.username);
|
||||
const availability: ComputedRef<string> = computed(
|
||||
() => user__.value.availability || ""
|
||||
);
|
||||
const availabilityChangedOn: ComputedRef<string> = computed(
|
||||
() => user__.value.availability_changed_on || ""
|
||||
);
|
||||
const timezone: ComputedRef<string> = computed(() => user__.value.time_zone);
|
||||
const userTeams: ComputedRef<string[]> = computed(
|
||||
() => user__.value.user_teams
|
||||
@@ -94,6 +100,8 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
userImage,
|
||||
userName,
|
||||
username,
|
||||
availability,
|
||||
availabilityChangedOn,
|
||||
timezone,
|
||||
userTeams,
|
||||
language,
|
||||
|
||||
@@ -30,38 +30,6 @@ def sent_invites(emails: list[str], send_welcome_mail_to_user: bool = True):
|
||||
return
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@agent_only
|
||||
def get_availability_options() -> list[str]:
|
||||
return frappe.get_all(
|
||||
"HD Agent Status",
|
||||
filters={"enable": 1},
|
||||
order_by="`tabHD Agent Status`.status_order asc",
|
||||
pluck="agent_status",
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@agent_only
|
||||
def get_my_availability() -> dict:
|
||||
name = get_agent_name()
|
||||
values = (
|
||||
frappe.db.get_value(
|
||||
"HD Agent",
|
||||
name,
|
||||
["availability", "availability_changed_on"],
|
||||
as_dict=True,
|
||||
)
|
||||
if name
|
||||
else {}
|
||||
) or {}
|
||||
return {
|
||||
"availability": values.get("availability"),
|
||||
"availability_changed_on": values.get("availability_changed_on"),
|
||||
"options": get_availability_options(),
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@agent_only
|
||||
def set_my_availability(availability: str) -> dict:
|
||||
|
||||
+14
-1
@@ -28,8 +28,19 @@ def get_user():
|
||||
is_admin = "System Manager" in roles or "Administrator" in roles
|
||||
|
||||
is_agent = _is_agent()
|
||||
has_agent_record = bool(get_agent_name())
|
||||
agent_name = get_agent_name()
|
||||
has_agent_record = bool(agent_name)
|
||||
has_desk_access = is_agent or is_admin
|
||||
availability = (
|
||||
frappe.db.get_value(
|
||||
"HD Agent",
|
||||
agent_name,
|
||||
["availability", "availability_changed_on"],
|
||||
as_dict=True,
|
||||
)
|
||||
if agent_name
|
||||
else None
|
||||
) or {}
|
||||
user_image = user.user_image
|
||||
user_first_name = user.first_name
|
||||
user_name = user.full_name
|
||||
@@ -56,6 +67,8 @@ def get_user():
|
||||
"time_zone": user.time_zone,
|
||||
"user_teams": user_team_names,
|
||||
"language": language,
|
||||
"availability": availability.get("availability"),
|
||||
"availability_changed_on": availability.get("availability_changed_on"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
from helpdesk.api.agent import get_my_availability, set_my_availability
|
||||
from helpdesk.api.agent import set_my_availability
|
||||
from helpdesk.api.auth import get_user
|
||||
from helpdesk.helpdesk.doctype.hd_agent.hd_agent import update_agent_role
|
||||
from helpdesk.test_utils import make_agent, make_team, make_ticket
|
||||
|
||||
@@ -57,16 +58,16 @@ class TestHDAgent(FrappeTestCase):
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
set_my_availability("Not A Status")
|
||||
|
||||
# get_my_availability returns the agent's status, when it changed, and options
|
||||
def test_get_my_availability_returns_status_and_options(self):
|
||||
# the session payload (auth.get_user) carries the agent's current status, so
|
||||
# the frontend seeds availability without a dedicated round-trip
|
||||
def test_get_user_includes_availability(self):
|
||||
frappe.set_user(self.test_user)
|
||||
set_my_availability("Away")
|
||||
|
||||
result = get_my_availability()
|
||||
result = get_user()
|
||||
|
||||
self.assertEqual(result["availability"], "Away")
|
||||
self.assertIsNotNone(result["availability_changed_on"])
|
||||
self.assertIn("Away", result["options"])
|
||||
|
||||
def _make_assignment_rule(self, team_name: str, members: list[str], rule: str):
|
||||
team = make_team(team_name, members)
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.different_view",
|
||||
"description": "Name shown on the customer portal<br> (e.g., Replied \u2192 Awaiting Response).",
|
||||
"description": "Name shown on the customer portal<br> (e.g., Replied → Awaiting Response).",
|
||||
"fieldname": "label_customer",
|
||||
"fieldtype": "Data",
|
||||
"label": "Label (customer view)",
|
||||
|
||||
Reference in New Issue
Block a user