Merge pull request #3045 from RitvikSardana/refactor/customer-contact

feat!: customer contact management
This commit is contained in:
RitvikSardana
2026-06-25 21:11:16 +05:30
committed by GitHub
143 changed files with 9763 additions and 2599 deletions
+1
View File
@@ -29,6 +29,7 @@ share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
.playwright-mcp
.claude-flow
.claude
CLAUDE.md
-2
View File
@@ -8,6 +8,4 @@ export {}
declare global {
const LucideAlignJustify: typeof import('~icons/lucide/align-justify')['default']
const LucideFileSearch: typeof import('~icons/lucide/file-search')['default']
const LucideKeyboard: typeof import('~icons/lucide/keyboard')['default']
const LucideSearch: typeof import("~icons/lucide/search")["default"]
}
+5 -5
View File
@@ -8,13 +8,13 @@
<script setup lang="ts">
import { Dialogs } from "@/components/dialogs";
import { useConfigStore } from "@/stores/config";
import { FrappeUIProvider, toast, setConfig, useTheme } from "frappe-ui";
import { useFavicon } from "@vueuse/core";
import { FrappeUIProvider, setConfig, toast, useTheme } from "frappe-ui";
import { storeToRefs } from "pinia";
import { computed, defineAsyncComponent, h, onMounted } from "vue";
import Wifi from "~icons/lucide/wifi";
import WifiOff from "~icons/lucide/wifi-off";
import { useAuthStore } from "./stores/auth";
import { useFavicon } from "@vueuse/core";
import { storeToRefs } from "pinia";
import { __ } from "./translation";
import { isCustomerPortal } from "./utils";
@@ -47,10 +47,10 @@ onMounted(() => {
});
const AgentPortalRoot = defineAsyncComponent(
() => import("@/pages/desk/AgentRoot.vue")
() => import("@/roots/AgentRoot.vue")
);
const CustomerPortalRoot = defineAsyncComponent(
() => import("@/pages/CustomerPortalRoot.vue")
() => import("@/roots/CustomerPortalRoot.vue")
);
const PortalRoot = computed(() => {
+282
View File
@@ -0,0 +1,282 @@
<template>
<div class="w-full h-full overflow-hidden">
<CardBase
:title="title"
:text="chartData.text"
:percentageChange="chartData.percentageChange"
:chartConfig="hasData ? chartConfig : placeholderChartConfig"
:currentDuration="currentDuration"
:orientation="orientation"
:timelineFilter="timelineFilter"
@changeDuration="changeDuration"
>
<template v-if="$slots.text" #text>
<slot name="text" v-bind="{ text: chartData.text }" />
</template>
</CardBase>
</div>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import { buildPercentageChange } from "@/utils";
import { EChartsOption } from "echarts";
import { createResource } from "frappe-ui";
import { computed, onMounted, ref, type PropType } from "vue";
import CardBase from "./ChartCardBase.vue";
interface BarChartData {
percentage_change?: number;
total?: number;
average?: number;
data: { date: string; count: number }[] | Record<number, number>;
total_reviews?: number;
}
const props = defineProps({
title: {
type: String,
required: true,
},
apiUrl: {
type: String,
required: false,
},
data: {
type: Object as PropType<BarChartData>,
required: true,
},
barColor: {
type: String,
default: "#3BBDE5",
},
dt: {
type: String,
required: false,
},
dn: {
type: String,
required: false,
},
orientation: {
type: String as PropType<"vertical" | "horizontal">,
default: "vertical",
},
measure: {
type: String as PropType<"total" | "average">,
default: "total",
},
negativeIsBetter: {
type: Boolean,
default: true,
},
timelineFilter: {
type: Boolean,
default: true,
},
});
const currentDuration = ref(__("Last month"));
const chartData = computed(() => {
const isDataFetched = resource.fetched;
const _data: BarChartData = isDataFetched ? resource.data : props.data;
// Star-distribution shape: data is { 1: n, 2: n, ... }
if (!props.timelineFilter && _data?.data && !Array.isArray(_data.data)) {
const distribution = _data.data as Record<number, number>;
const values = [1, 2, 3, 4, 5].map((s) => distribution[s] ?? 0);
return {
labels: ["1", "2", "3", "4", "5"],
counts: values,
percentageChange: buildPercentageChange(0, props.negativeIsBetter),
text: _data?.average ?? 0,
isStarDistribution: true,
};
}
// Time-series shape: data is [{ date, count }, ...]
const timeData = Array.isArray(_data?.data)
? (_data.data as { date: string; count: number }[])
: [];
const labels = timeData.map((item) => item.date);
const counts = timeData.map((item) => item.count);
const _percentageChange = _data?.percentage_change || 0;
const percentageChange = buildPercentageChange(
_percentageChange,
props.negativeIsBetter
);
return {
labels,
counts,
percentageChange,
text: props.measure === "average" ? _data?.average ?? 0 : _data?.total ?? 0,
isStarDistribution: false,
};
});
const hasData = computed(() => {
const counts = chartData.value.counts as number[];
return counts.length > 0 && counts.some((c) => c > 0);
});
const chartConfig = computed<EChartsOption>(() => {
const color = props.barColor;
if (chartData.value.isStarDistribution) {
const values = chartData.value.counts as number[];
// rank by value descending: index 0 = highest, 4 = lowest
const opacities = [1, 0.7, 0.5, 0.3, 0.2];
const baseColor = props.barColor; // #E79913
// unique sorted counts descending (for tie-aware rank lookup)
const uniqueSorted = [...new Set(values)].sort((a, b) => b - a);
const opacityByIndex: number[] = values.map((v) => {
const rank = uniqueSorted.indexOf(v); // same count → same rank
return opacities[Math.min(rank, opacities.length - 1)];
});
// helper: hex + opacity → rgba
const hexToRgba = (hex: string, alpha: number) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
};
return {
grid: { left: 4, right: 4, top: 16, bottom: 20, containLabel: false },
xAxis: {
type: "category",
data: ["1", "2", "3", "4", "5"],
axisLine: { show: true, lineStyle: { color: "#ededed" } },
axisTick: { show: false },
axisLabel: { color: "#6b7280", fontSize: 11 },
},
yAxis: { type: "value", show: false, min: 0 },
series: [
{
type: "bar",
data: values.map((value, i) => ({
value: value === 0 ? 0 : value,
itemStyle: {
color: hexToRgba(baseColor, opacityByIndex[i]),
borderRadius: [4, 4, 0, 0],
},
label: {
show: true,
position: "top" as const,
formatter: value > 0 ? String(value) : "",
color: "#6b7280",
fontSize: 11,
},
})),
barWidth: 16,
emphasis: { disabled: true },
},
],
tooltip: { show: false },
};
}
return {
xAxis: {
type: "category",
data: chartData.value.labels,
show: false,
boundaryGap: true,
},
yAxis: {
type: "value",
show: false,
},
series: [
{
data: (chartData.value.counts as number[]).map((value) => ({
value,
itemStyle: {
color: value === 0 ? "#e5e7eb" : color,
borderRadius: [3, 3, 0, 0],
},
})),
type: "bar",
barMaxWidth: 12,
barMinHeight: 4,
emphasis: { disabled: true },
label: { show: false },
},
],
grid: {
left: 2,
right: 2,
top: 2,
bottom: 2,
},
tooltip: {
show: true,
trigger: "axis",
appendToBody: true,
position: (point: number[]) => [point[0] + 12, point[1] - 50],
axisPointer: { type: "none" },
formatter: (params: any) => {
const p = Array.isArray(params) ? params[0] : params;
if (!p.value) return "";
return `<span style="font-size:12px;color:#6b7280">${p.name}: <b style="color:#374151">${p.value}</b></span>`;
},
},
};
});
const placeholderChartConfig = computed<EChartsOption>(() => {
const placeholderValues = Array.from(
{ length: 7 },
() => Math.floor(Math.random() * 8) + 3
);
return {
xAxis: { type: "category", show: false, boundaryGap: true },
yAxis: { type: "value", show: false },
series: [
{
data: placeholderValues.map((value) => ({
value,
itemStyle: { color: "#f1f1f1", borderRadius: [3, 3, 0, 0] },
})),
type: "bar",
barMaxWidth: 12,
emphasis: { disabled: true },
},
],
grid: { left: 2, right: 2, top: 2, bottom: 2 },
tooltip: { show: false },
animation: false,
};
});
const resource = createResource({
url: props.apiUrl || "",
type: "GET",
makeParams: () => {
const params: Record<string, any> = {
period: currentDuration.value.toLowerCase(),
};
if (props.dt && props.dn) {
params.dt = props.dt;
params.dn = props.dn;
}
return params;
},
});
const changeDuration = (period: string) => {
currentDuration.value = period;
if (props.apiUrl) resource.submit();
};
onMounted(() => {
if (props.timelineFilter && props.apiUrl && !props.data?.data) {
resource.submit();
}
});
</script>
@@ -1,18 +1,26 @@
<template>
<div class="flex flex-col w-full h-full rounded-md p-4">
<div
class="flex flex-col w-full h-full rounded-md p-4"
:class="[orientation == 'horizontal' && 'pt-3']"
>
<slot name="title">
<div class="text-ink-gray-5 text-base mb-2">
{{ title }}
</div>
</slot>
<div class="flex flex-col gap-2 h-full w-full">
<div
class="flex flex-col gap-2 h-full w-full"
v-if="orientation === 'vertical'"
>
<div class="flex items-end w-full gap-2">
<div
class="text-2xl font-medium text-center text-ink-gray-8 whitespace-nowrap"
>
{{ text }}
</div>
<div class="flex items-center text-sm gap-1">
<slot name="text">
<div
class="text-2xl font-medium text-center text-ink-gray-8 whitespace-nowrap"
>
{{ text }}
</div>
</slot>
<div v-if="timelineFilter" class="flex items-center text-sm gap-1">
<div class="flex items-center gap-1" :class="percentageChange.color">
<span :class="percentageChange.icon" class="size-4" />
<div>{{ percentageChange.value }}%</div>
@@ -51,15 +59,60 @@
</div>
</slot>
</div>
<div class="flex flex-col gap-2 h-full w-full" v-else>
<div class="flex items-end w-full gap-2 justify-between flex-1">
<slot name="text">
<span
class="text-2xl font-medium text-center text-ink-gray-8 whitespace-nowrap"
>
{{ text }}
</span>
</slot>
<slot name="chart">
<div v-if="chartConfig" class="h-full max-w-[50%] w-[50%]">
<ECharts :options="chartConfig" class="w-full h-full" />
</div>
</slot>
</div>
<div v-if="timelineFilter" class="flex items-center text-sm gap-1">
<div class="flex items-center gap-1" :class="percentageChange.color">
<span :class="percentageChange.icon" class="size-4" />
<div>{{ percentageChange.value }}%</div>
</div>
<Dropdown :options="durationOptions">
<div
class="flex items-center gap-0.5 text-ink-gray-5 hover:text-ink-gray-6 cursor-pointer shrink-0"
>
vs {{ currentDuration.toLowerCase() }}
<FeatherIcon name="chevron-down" class="size-4" />
</div>
<template #item-label="{ item }">
<div
class="data-[disabled]:cursor-not-allowed group flex w-full items-center rounded px-2 text-base focus:outline-none focus:bg-surface-gray-3 data-[highlighted]:bg-surface-gray-3 data-[state=open]:bg-surface-gray-3 whitespace-nowrap text-ink-gray-7 cursor-pointer justify-between"
>
<span>
{{ item.label }}
</span>
</div>
</template>
<template #item-suffix="{ item }">
<FeatherIcon
v-if="item.label == __(currentDuration)"
name="check"
class="size-4"
/>
</template>
</Dropdown>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, type PropType } from "vue";
import { Dropdown, FeatherIcon, ECharts } from "frappe-ui";
import { __ } from "@/translation";
import { EChartsOption } from "echarts";
import { DropdownOption } from "@/types";
import { Dropdown, ECharts, FeatherIcon } from "frappe-ui";
import { computed, type PropType } from "vue";
const props = defineProps({
title: {
@@ -81,6 +134,14 @@ const props = defineProps({
type: String,
default: __("Last month"),
},
orientation: {
type: String as PropType<"vertical" | "horizontal">,
default: "vertical",
},
timelineFilter: {
type: Boolean,
default: true,
},
});
const chartConfig = computed<EChartsOption>(() => props.chartConfig);
@@ -0,0 +1,87 @@
<template>
<Dialog v-model:open="open" :title="dialogTitle" size="md">
<template #default>
<div class="flex flex-col gap-4">
<p class="text-p-base text-ink-gray-7">{{ message }}</p>
<Checkbox
v-if="count"
size="sm"
v-model="deleteLinkedTickets"
:label="__('Delete {0} ticket(s)', [count])"
/>
</div>
</template>
<template #actions>
<div class="flex justify-end">
<Button
variant="solid"
theme="red"
icon-left="trash-2"
:label="__('Delete')"
:loading="isDeleting"
@click="confirmDelete"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import { Button, Checkbox, createListResource, Dialog } from "frappe-ui";
import { computed, ref, watch } from "vue";
const props = defineProps<{
/** Name of the record being deleted. */
name: string;
/** HD Ticket link field that ties tickets to this record. */
linkField: "customer" | "contact";
/** Confirmation copy shown above the checkbox. */
message: string;
title?: string;
/** Async delete handler; the button stays in a loading state until it settles. */
onDelete: (payload: { deleteLinkedTickets: boolean }) => Promise<void> | void;
}>();
const open = defineModel<boolean>({ default: false });
const resource = createListResource({
doctype: "HD Ticket",
filters: { [props.linkField]: props.name },
fields: ["name"],
limit: 99999, // We just want the count
});
const count = computed<number>(() => {
if (resource.loading) return 0;
return resource.data?.length ?? 0;
});
const deleteLinkedTickets = ref(false);
const isDeleting = ref(false);
const dialogTitle = computed(() => props.title ?? __("Delete"));
async function confirmDelete() {
if (isDeleting.value) return;
isDeleting.value = true;
try {
await props.onDelete({ deleteLinkedTickets: deleteLinkedTickets.value });
open.value = false;
} catch {
// Keep the dialog open on failure; the handler surfaces the error.
} finally {
isDeleting.value = false;
}
}
watch(
open,
(isOpen) => {
if (isOpen) {
resource.reload();
}
},
{ immediate: true }
);
</script>
+20 -11
View File
@@ -32,11 +32,15 @@
</div>
<div class="mx-6 md:mx-5 flex items-center gap-2 border-y py-2.5">
<span class="text-p-xs text-ink-gray-4">{{ __("To") }}:</span>
<MultiSelectInput
<EmailMultiSelect
v-model="toEmailsClone"
class="flex-1"
scope="contact"
variant="ghost"
allow-custom-email
copy-on-click
:validate="validateEmailWithZod"
:error-message="(value) => `${value} is an invalid email address`"
:custom-email-label="__('Add to recipients')"
/>
<div class="flex gap-1.5">
<Button
@@ -67,12 +71,16 @@
:class="cc || showCC ? 'border-b' : ''"
>
<span class="text-xs text-ink-gray-4">{{ __("Cc:") }}</span>
<MultiSelectInput
<EmailMultiSelect
ref="ccInput"
v-model="ccEmailsClone"
class="flex-1"
scope="contact"
variant="ghost"
allow-custom-email
copy-on-click
:validate="validateEmailWithZod"
:error-message="(value) => `${value} is an invalid email address`"
:custom-email-label="__('Add to recipients')"
/>
</div>
<div
@@ -81,12 +89,16 @@
:class="bcc || showBCC ? 'border-b' : ''"
>
<span class="text-xs text-ink-gray-4">{{ __("Bcc:") }}</span>
<MultiSelectInput
<EmailMultiSelect
ref="bccInput"
v-model="bccEmailsClone"
class="flex-1"
scope="contact"
variant="ghost"
allow-custom-email
copy-on-click
:validate="validateEmailWithZod"
:error-message="(value) => `${value} is an invalid email address`"
:custom-email-label="__('Add to recipients')"
/>
</div>
</template>
@@ -202,11 +214,8 @@
</template>
<script setup lang="ts">
import {
AttachmentItem,
MultiSelectInput,
SavedRepliesSelectorModal,
} from "@/components";
import { AttachmentItem, SavedRepliesSelectorModal } from "@/components";
import EmailMultiSelect from "@/components/EmailMultiSelect.vue";
import { AttachmentIcon } from "@/components/icons";
import { useTyping } from "@/composables/realtime";
import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
+391
View File
@@ -0,0 +1,391 @@
<template>
<div>
<div class="flex flex-wrap gap-1">
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
:tooltip="copyOnClick ? __('Click to copy') : undefined"
:class="[
{
'rounded bg-surface-white hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
variant === 'subtle',
},
copyOnClick
? 'cursor-pointer transition-transform active:scale-[0.98]'
: '',
]"
@click="copyOnClick ? copy(value) : null"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeValue(value)"
/>
</template>
</Button>
<div class="flex-1 min-w-32">
<ComboboxRoot
:model-value="tempSelection"
:open="showOptions"
@update:open="(o) => (showOptions = o)"
@update:modelValue="onSelect"
:ignore-filter="true"
highlight-on-hover
>
<ComboboxAnchor
class="flex h-7 max-w-full w-auto items-center gap-2 rounded px-2 py-1 border border-transparent"
:class="[
variant == 'ghost'
? 'bg-surface-white hover:bg-surface-white'
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
inputClass,
]"
>
<ComboboxInput
ref="search"
:value="query"
autocomplete="off"
class="bg-transparent p-0 outline-none border-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 w-full focus:outline-none focus:ring-0 focus:border-0"
:placeholder="placeholder"
@focus="showOptions = true"
@input="onInput"
@keydown.delete.capture.stop="removeLastValue"
@keydown="onKeydown"
@paste="onPaste"
/>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
class="z-10 mt-1 min-w-48 w-auto max-w-96 bg-surface-modal overflow-hidden rounded-lg shadow-2xl ring-1 ring-black ring-opacity-5"
position="popper"
:align="'start'"
@openAutoFocus.prevent
@closeAutoFocus.prevent
>
<ComboboxViewport class="max-h-60 overflow-auto p-1.5">
<ComboboxEmpty
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
>
<FeatherIcon name="search" class="h-4" />
{{ __(emptyPlaceholder) }}
</ComboboxEmpty>
<ComboboxItem
v-for="option in options"
:key="option.value"
:value="option.value"
class="text-base leading-none text-ink-gray-7 rounded flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
@mousedown.prevent="onSelect(option.value)"
>
<UserAvatar
class="mr-0.5"
:name="getUsernameLabel(option.label)"
size="lg"
/>
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base font-medium">
{{ getUsernameLabel(option.label) }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.isCustom ? customEmailLabel : option.value }}
</div>
</div>
</ComboboxItem>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</div>
</div>
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
<div
v-if="info"
class="whitespace-pre-line text-sm text-ink-blue-3 mt-2 pl-2"
>
{{ info }}
</div>
</div>
</template>
<script setup>
import UserAvatar from "@/components/UserAvatar.vue";
import { useUserStore } from "@/stores/user";
import { copy } from "@/utils";
import { watchDebounced } from "@vueuse/core";
import { createResource } from "frappe-ui";
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot,
ComboboxViewport,
} from "reka-ui";
import { computed, nextTick, onMounted, ref, watch } from "vue";
const props = defineProps({
// "contact" searches HD contacts; "agents" picks from the user store.
scope: { type: String, default: "contact" },
validate: { type: Function, default: null },
emptyPlaceholder: { type: String, default: __("No results found") },
variant: { type: String, default: "subtle" },
placeholder: { type: String, default: "" },
inputClass: { type: String, default: "" },
existingUsers: { type: Array, default: () => [] },
allowCustomEmail: { type: Boolean, default: false },
additionalFilters: { type: Array, default: () => [] },
copyOnClick: { type: Boolean, default: false },
customEmailLabel: { type: String, default: __("Invite via email") },
});
const errorMessage = (value) => __("{0} is an Invalid Email Address", [value]);
// v-model values
const values = defineModel();
// Common state
const emails = ref([]);
const search = ref(null);
const error = ref(null);
const info = ref(null);
const query = ref("");
const showOptions = ref(false);
const tempSelection = ref(null);
const { users } = useUserStore();
// --- Contacts resource (used when scope = "contact") ---
const filterContacts = createResource({
url: "helpdesk.api.contact.search_contacts",
method: "GET",
params: contactSearchParams(""),
transform: (data) =>
data.map(({ full_name, email_id, name }) => ({
label: full_name || email_id || name,
value: email_id || name,
email: email_id,
})),
});
watchDebounced(
query,
(newVal) => {
if (props.scope === "agents") return;
filterContacts.update({ params: contactSearchParams(newVal) });
filterContacts.reload();
},
{ debounce: 250, immediate: true }
);
function contactSearchParams(txt) {
const params = { txt: txt || "" };
if (props.additionalFilters?.length) {
params.additional_filters = JSON.stringify(props.additionalFilters);
}
return params;
}
// --- Options ---
const options = computed(() => {
if (props.scope === "agents") {
let list = (users?.data || []).map((u) => ({
label: u.full_name || u.name,
value: u.name,
}));
if (values.value?.length) {
list = list.filter((o) => !values.value.includes(o.value));
}
if (props.existingUsers?.length) {
list = list.filter((o) => !props.existingUsers.includes(o.value));
}
if (query.value) {
const q = query.value.toLowerCase();
list = list.filter(
(o) =>
o.label?.toLowerCase().includes(q) ||
o.value?.toLowerCase().includes(q)
);
}
return list;
}
// contacts mode
let list = filterContacts.data || [];
if (values.value?.length) {
list = list.filter((o) => !values.value.includes(o.value));
}
if (props.existingUsers?.length) {
list = list.filter((o) => !isExistingUser(o.email));
}
// offer the typed text itself when nothing matches, like the email To field
if (
props.allowCustomEmail &&
query.value &&
!list.length &&
!isExistingUser(query.value)
) {
list = [{ label: query.value, value: query.value, isCustom: true }];
}
return list;
});
function isExistingUser(email) {
if (!email) return false;
return props.existingUsers?.some(
(user) => user?.toLowerCase?.() === email.toLowerCase()
);
}
function addValue(input) {
if (!input) return;
error.value = null;
info.value = null;
const parts = splitRecipients(input);
for (const email of parts) {
if (values.value?.includes(email)) continue;
const inList = options.value.some((o) => o.value === email && !o.isCustom);
if (!inList && !canAddCustomValue(email)) break;
if (!values.value) values.value = [email];
else values.value.push(email);
}
}
function canAddCustomValue(email) {
if (!props.allowCustomEmail) {
error.value =
props.scope === "agents"
? __("{0} is not an existing user", [email])
: __("{0} is not an existing contact", [email]);
query.value = email;
return false;
}
if (isExistingUser(email)) {
error.value = __("{0} is already added", [email]);
query.value = email;
return false;
}
if (props.validate && !props.validate(email)) {
error.value = errorMessage(email);
query.value = email;
return false;
}
return true;
}
// Split on commas/semicolons/newlines while keeping a quoted display name
// intact, so '"Doe, John" <[email protected]>' stays a single token.
function splitRecipients(input) {
const parts = [];
let current = "";
let inQuotes = false;
for (const char of input) {
if (char === '"') {
inQuotes = !inQuotes;
current += char;
} else if ((char === "," || char === ";" || char === "\n") && !inQuotes) {
if (current.trim()) parts.push(current.trim());
current = "";
} else {
current += char;
}
}
if (current.trim()) parts.push(current.trim());
return parts;
}
// Reduce a "Name <email>" value to just the display name for rendering.
function getUsernameLabel(value) {
if (!value) return "";
return value.split("<")[0].replace(/"/g, "").trim();
}
function removeValue(value) {
values.value = values.value.filter((v) => v !== value);
}
function removeLastValue() {
if (query.value) return;
let emailRef = emails.value[emails.value.length - 1]?.rootRef;
if (document.activeElement === emailRef) {
values.value.pop();
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].rootRef;
emailRef?.focus();
} else {
setFocus();
}
});
} else {
emailRef?.focus();
}
}
function setFocus() {
setTimeout(() => {
search.value?.$el?.focus();
}, 200);
}
function onInput(e) {
query.value = e.target.value;
error.value = null;
info.value = null;
showOptions.value = true;
}
function onSelect(val) {
if (!val) return;
addValue(val);
if (error.value) {
showOptions.value = false;
return;
}
query.value = "";
tempSelection.value = null;
showOptions.value = true;
nextTick(() => setFocus());
}
// Commit the current query as a pill on Enter, comma or space.
function onKeydown(e) {
const isCommitKey = e.key === "Enter" || e.key === "," || e.key === " ";
if (!isCommitKey || !query.value.trim()) return;
e.preventDefault();
onSelect(query.value);
}
// Split a pasted, comma/space/newline-separated list into multiple pills.
function onPaste(e) {
const text = e.clipboardData?.getData("text") ?? "";
if (!/[\s,;]/.test(text)) return;
e.preventDefault();
addValue(text);
if (!error.value) {
query.value = "";
tempSelection.value = null;
nextTick(() => setFocus());
}
}
watch(options, (list) => {
tempSelection.value = list[0]?.value ?? null;
});
onMounted(() => {
// reka-ui hardcodes autocomplete="off", which Chrome ignores for fields it
// reads as email/address. "new-password" is the value Chrome won't autofill,
// so its native address dropdown stops covering our options. (No password UI:
// the input is type=text and not inside a form.)
search.value?.$el?.setAttribute("autocomplete", "new-password");
});
defineExpose({ setFocus });
</script>
+91
View File
@@ -0,0 +1,91 @@
<template>
<div class="flex items-center gap-3 mb-2">
<FileUploader :fileTypes="['image/*']" @success="onSuccess">
<template #default="{ openFileSelector, uploading }">
<!-- Filled: image with hover-to-replace + remove badge -->
<div v-if="image" class="group relative size-11.5 shrink-0">
<Avatar
:image="image"
:label="fallbackLabel"
:shape="shape"
size="3xl"
/>
<button
type="button"
class="absolute inset-0 flex cursor-pointer items-center justify-center bg-black/40 text-white opacity-0 outline-none transition focus:outline-none focus-visible:outline-none group-hover:opacity-100"
:class="roundedClass"
:aria-label="__('Replace image')"
@click.prevent="openFileSelector()"
>
<LucidePencil class="size-4" />
</button>
<button
type="button"
class="absolute -right-2.5 -top-2.5 flex size-4.5 cursor-pointer items-center justify-center rounded-full bg-surface-white text-ink-gray-4 opacity-0 outline outline-black-overlay-50 duration-300 ease-in-out focus:outline-none focus-visible:outline-none hover:bg-surface-gray-2 group-hover:opacity-100"
:aria-label="__('Remove image')"
@click.prevent="image = ''"
>
<LucideX class="size-2.5" />
</button>
</div>
<!-- Empty: upload box -->
<button
v-else
type="button"
class="flex size-11.5 shrink-0 cursor-pointer items-center justify-center border border-outline-gray-2 text-ink-gray-4 outline-none transition focus:outline-none focus-visible:outline-none hover:border-outline-gray-3"
:class="roundedClass"
:disabled="uploading"
:aria-label="label || __('Upload image')"
@click.prevent="openFileSelector()"
>
<LucideUpload class="size-4.5" />
</button>
</template>
</FileUploader>
<div v-if="label || description" class="flex flex-col gap-0.5">
<span v-if="label" class="text-p-sm font-medium text-ink-gray-8">
{{ __(label) }}
</span>
<span v-if="description" class="text-p-sm text-ink-gray-5">
{{ __(description) }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import type { File as FileType } from "@/types";
import { Avatar, FileUploader } from "frappe-ui";
import { computed } from "vue";
import LucidePencil from "~icons/lucide/pencil";
import LucideUpload from "~icons/lucide/upload";
import LucideX from "~icons/lucide/x";
const image = defineModel<string>({ default: "" });
const props = withDefaults(
defineProps<{
label?: string;
description?: string;
fallbackLabel?: string;
shape?: "circle" | "square";
}>(),
{
label: "",
description: __("Upload an image in PNG or JPG format"),
fallbackLabel: "",
shape: "square",
}
);
const roundedClass = computed(() =>
props.shape === "circle" ? "rounded-full" : "rounded-[10px]"
);
function onSuccess(file: FileType) {
image.value = file.file_url;
}
</script>
@@ -2,27 +2,31 @@
<div class="w-full h-full overflow-hidden">
<CardBase
:title="title"
:text="chartData.average"
:text="chartData.text"
:percentageChange="chartData.percentageChange"
:chartConfig="chartConfig"
:currentDuration="currentDuration"
:orientation="orientation"
:timelineFilter="timelineFilter"
@changeDuration="changeDuration"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, type PropType } from "vue";
import CardBase from "./CardBase.vue";
import { createResource } from "frappe-ui";
import { buildPercentageChange, formatTime } from "@/utils";
import { __ } from "@/translation";
import { buildPercentageChange, formatTime } from "@/utils";
import { EChartsOption } from "echarts";
import { createResource } from "frappe-ui";
import { computed, onMounted, ref, type PropType } from "vue";
import CardBase from "./ChartCardBase.vue";
interface AverageResponseData {
percentage_change: number;
average: number;
data: { date: string; avg_time: number }[];
average?: number;
total?: number;
data: { date: string; avg_time?: number; count?: number }[];
}
const props = defineProps({
@@ -32,7 +36,11 @@ const props = defineProps({
},
apiUrl: {
type: String,
required: true,
required: false,
},
timelineFilter: {
type: Boolean,
default: true,
},
data: {
type: Object as PropType<AverageResponseData>,
@@ -45,6 +53,22 @@ const props = defineProps({
}>,
required: true,
},
dt: {
type: String,
required: false,
},
dn: {
type: String,
required: false,
},
orientation: {
type: String as PropType<"vertical" | "horizontal">,
default: "vertical",
},
type: {
type: String as PropType<"Time" | "Count">,
default: "Time",
},
});
const currentDuration = ref(__("Last month"));
@@ -54,19 +78,30 @@ const chartData = computed(() => {
const _data: AverageResponseData = isDataFetched ? resource.data : props.data;
const dates = _data?.data?.map((item) => item.date) || [];
const avg_time = _data?.data?.map((item) => item.avg_time) || [];
const _percentageChange = _data?.percentage_change ?? null;
const seriesData =
_data?.data?.map((item) =>
props.type === "Time" ? item.avg_time : item.count
) || [];
const _percentageChange = _data?.percentage_change || 0;
const percentageChange = buildPercentageChange(_percentageChange);
const average =
formatTime(_data?.average || 0, { day: true, hour: true, minute: true }) ||
"0m";
// for time take average and for count take total
const text =
props.type === "Time"
? formatTime(_data?.average || 0, {
day: true,
hour: true,
minute: true,
maxUnits: 2,
}) || "0m"
: _data?.total || 0;
return {
data: avg_time,
data: seriesData,
dates,
percentageChange,
average,
text,
};
});
@@ -122,26 +157,52 @@ const chartConfig = computed<EChartsOption>(() => {
top: 2,
bottom: 2,
},
tooltip: {
show: true,
trigger: "axis",
appendToBody: true,
// position: (point: number[]) => [point[0] + 12, point[1] - 50],
axisPointer: { type: "none" },
formatter: (params: any) => {
const p = Array.isArray(params) ? params[0] : params;
if (p.value === undefined || p.value === null) return "";
const value =
props.type === "Time"
? formatTime(Number(p.value) || 0, {
day: true,
hour: true,
minute: true,
maxUnits: 2,
}) || "0m"
: p.value;
return `<span style="font-size:12px;color:#6b7280">${p.name}: <b style="color:#374151">${value}</b></span>`;
},
},
};
});
const resource = createResource({
url: props.apiUrl,
url: props.apiUrl || "",
type: "GET",
makeParams: () => {
return {
const params: Record<string, any> = {
period: currentDuration.value.toLowerCase(),
};
if (props.dt && props.dn) {
params.dt = props.dt;
params.dn = props.dn;
}
return params;
},
});
const changeDuration = (period: string) => {
currentDuration.value = period;
resource.submit();
if (props.apiUrl) resource.submit();
};
onMounted(() => {
if (!props.data?.data) {
if (props.timelineFilter && props.apiUrl && !props.data?.data) {
resource.submit();
}
});
-271
View File
@@ -1,271 +0,0 @@
<template>
<div>
<div class="flex flex-wrap gap-1">
<Button
v-for="value in values"
ref="emails"
:key="value"
:label="value"
theme="gray"
variant="subtle"
tooltip="Click to copy"
class="cursor-pointer transition-transform active:scale-[0.98]"
@click="copy(value)"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<FeatherIcon
class="h-3.5 cursor-pointer transition-transform active:scale-[0.96]"
name="x"
@click.stop="removeValue(value)"
/>
</template>
</Button>
<div class="flex-1">
<Combobox v-model="selectedValue" nullable>
<Popover v-model:show="showOptions" class="w-full">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full border-none bg-surface-white hover:bg-surface-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
type="text"
:value="query"
autocomplete="off"
@change="
(e) => {
query = e.target.value;
showOptions = true;
}
"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base shadow-2xl"
>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
>
<ComboboxOption
v-for="option in options"
:key="option.value"
v-slot="{ active }"
:value="option"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<UserAvatar
class="me-2"
:name="getUsernameLabel(option.value)"
size="lg"
/>
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base font-medium">
{{ getUsernameLabel(option.label) }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</div>
</template>
</Popover>
</Combobox>
</div>
</div>
<ErrorMessage v-if="error" class="mt-2 ps-2" :message="error" />
</div>
</template>
<script setup lang="ts">
import { UserAvatar } from "@/components/";
import { copy } from "@/utils";
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
import { watchDebounced } from "@vueuse/core";
import { Button, Popover, createResource } from "frappe-ui";
import { computed, nextTick, ref } from "vue";
const props = defineProps({
validate: {
type: Function,
default: null,
},
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
});
const values = defineModel();
const emails = ref([]);
const search = ref(null);
const error = ref(null);
const query = ref("");
const text = ref("");
const showOptions = ref(false);
const selectedValue = computed({
get: () => query.value || "",
set: (val) => {
query.value = "";
if (val) {
showOptions.value = false;
}
val?.value && addValue(val.value);
},
});
watchDebounced(
query,
(val) => {
val = val || "";
if (text.value === val) return;
text.value = val;
reload(val);
},
{ debounce: 400, immediate: true }
);
const filterOptions = createResource({
url: "helpdesk.api.contact.search_contacts",
method: "GET",
cache: ["Contact", text.value],
params: {
txt: text.value,
},
transform: (data: Record<"full_name" | "name" | "email_id", string>[]) => {
return data.map(({ full_name, email_id, name }) => ({
label: full_name || name || email_id,
value: email_id,
}));
},
});
const options = computed(() => {
let searchedContacts = filterOptions.data || [];
if (!searchedContacts.length && query.value) {
searchedContacts.push({
label: query.value,
value: query.value,
});
}
return searchedContacts;
});
function reload(val) {
filterOptions.update({
params: {
txt: val,
},
});
filterOptions.reload();
}
const normalizeAndFilter = (field) => {
let arr = [];
let current = "";
let inQuotes = false;
for (let char of field) {
if (char === '"') {
inQuotes = !inQuotes;
current += char;
} else if (char === "," && !inQuotes) {
if (current.trim()) {
arr.push(current.trim());
}
current = "";
} else {
current += char;
}
}
if (current.trim()) {
arr.push(current.trim());
}
return arr;
};
const addValue = (value) => {
error.value = null;
if (value) {
const splitValues = normalizeAndFilter(value);
splitValues.forEach((value) => {
value = value.trim();
if (value) {
// check if value is not already in the values array
if (!values.value?.includes(value)) {
// check if value is valid
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value);
return;
}
// add value to values array
if (!values.value) {
values.value = [value];
} else {
values.value.push(value);
}
value = value.replace(value, "");
}
}
});
!error.value && (value = "");
}
};
const getUsernameLabel = (value: string) => {
if (!value) return "";
// Take everything before '<'
const beforeAngle = value.split("<")[0];
// Remove quotes and trim
return beforeAngle.replace(/"/g, "").trim();
};
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value);
};
function removeLastValue() {
if (query.value) return;
let emailRef = emails.value[emails.value.length - 1]?.rootRef;
if (document.activeElement === emailRef) {
values.value.pop();
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].rootRef;
emailRef?.focus();
} else {
setFocus();
}
});
} else {
emailRef?.focus();
}
}
function setFocus() {
search.value?.focus?.();
}
defineExpose({ setFocus });
</script>
+86
View File
@@ -0,0 +1,86 @@
<template>
<div class="flex items-center justify-between p-5 pb-0">
<div class="gap-x-3 flex h-full">
<Avatar
:size="avatar.size ?? '3xl'"
:shape="avatar.shape ?? 'square'"
:label="avatar.label"
:image="avatar.image"
class="h-[52px] w-[52px]"
/>
<div
class="flex flex-col h-full gap-1.5"
:class="[isMobileView && 'flex-1 items-center justify-center']"
>
<div class="flex gap-2 items-center">
<p class="font-medium text-ink-gray-8 text-xl">
{{ avatar.label }}
</p>
<Tooltip v-if="badge" :text="badge.tooltip ?? ''">
<Badge :label="badge.label" :theme="badge.theme ?? 'orange'" />
</Tooltip>
</div>
<div v-if="!isMobileView" class="flex items-center gap-x-1.5">
<template v-for="(item, index) in docInfo" :key="index">
<template v-if="item.condition !== false">
<span
v-if="
docInfo.slice(0, index).some((i) => i.condition !== false)
"
class="text-ink-gray-4"
>
•
</span>
<div class="flex items-center gap-x-1">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 text-ink-gray-6"
/>
<span v-if="item.value" class="text-sm text-ink-gray-8">{{
item.value
}}</span>
<template v-else-if="item.component">
<component :is="item.component" />
</template>
</div>
</template>
</template>
</div>
</div>
</div>
<slot name="actions" />
</div>
</template>
<script setup lang="ts">
import { useScreenSize } from "@/composables/screen";
import { Avatar, Badge, Tooltip, type AvatarProps } from "frappe-ui";
interface DocInfoItem {
icon?: any;
value?: string;
component?: any;
condition?: boolean;
}
interface BadgeInfo {
label: string;
theme?: "gray" | "blue" | "green" | "orange" | "red";
tooltip?: string;
}
const props = withDefaults(
defineProps<{
avatar: AvatarProps;
docInfo?: DocInfoItem[];
badge?: BadgeInfo | null;
}>(),
{
docInfo: () => [],
badge: null,
}
);
const { isMobileView } = useScreenSize();
</script>
+9
View File
@@ -1,5 +1,6 @@
<template>
<Button
ref="buttonRef"
:key="label"
theme="gray"
variant="outline"
@@ -13,7 +14,9 @@
</template>
<script setup>
import { Button } from "frappe-ui";
import { computed, ref } from "vue";
import IconX from "~icons/lucide/x";
defineProps({
label: {
type: String,
@@ -28,7 +31,13 @@ defineProps({
const emit = defineEmits(["click"]);
const buttonRef = ref(null);
const handleClick = (label) => {
emit("click", label);
};
defineExpose({
rootRef: computed(() => buttonRef.value?.rootRef),
});
</script>
+18 -25
View File
@@ -103,25 +103,27 @@
</template>
<script setup lang="ts">
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { useAuthStore } from "@/stores/auth";
import { capture } from "@/telemetry";
import { __ } from "@/translation";
import { handleInviteUserSuccess } from "@/utils";
import {
Button,
FormControl,
Select,
Button,
Tooltip,
createResource,
toast,
} from "frappe-ui";
import { computed, ref } from "vue";
import { useOnboarding } from "frappe-ui/frappe";
import { computed, ref } from "vue";
import LucideChevronDown from "~icons/lucide/chevron-down";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { capture } from "@/telemetry";
import { __ } from "@/translation";
const authStore = useAuthStore();
const { isAdmin, isManager } = authStore;
// @ts-expect-error
const { updateOnboardingStep } = useOnboarding("helpdesk");
const emails = ref("");
@@ -199,8 +201,6 @@ const resetInputValues = () => {
role.value = roleOptions[0].value;
};
const emailsToStr = (emails: readonly string[]) => emails.join(", ");
const inviteByEmailResource = createResource({
url: "frappe.core.api.user_invitation.invite_by_email",
onSuccess(
@@ -213,22 +213,7 @@ const inviteByEmailResource = createResource({
>
) {
resetInputValues();
let emailsStr = emailsToStr(data.invited_emails);
if (emailsStr.trim() !== "") {
toast.success(`${emailsStr} invited successfully`);
}
emailsStr = emailsToStr(data.disabled_user_emails);
if (emailsStr.trim() !== "") {
toast.info(`${emailsStr} already present and disabled`);
}
emailsStr = emailsToStr(data.pending_invite_emails);
if (emailsStr.trim() !== "") {
toast.info(`${emailsStr} already invited`);
}
emailsStr = emailsToStr(data.accepted_invite_emails);
if (emailsStr.trim() !== "") {
toast.info(`${emailsStr} already present`);
}
handleInviteUserSuccess(data);
pendingInvitesResource.reload();
updateOnboardingStep("invite_your_team");
capture("agents_invited", {
@@ -241,16 +226,24 @@ const inviteByEmailResource = createResource({
const pendingInvitesResource = createResource({
url: "frappe.core.api.user_invitation.get_pending_invitations",
params: { app_name: "helpdesk" },
params: {
app_name: "helpdesk",
},
auto: true,
method: "GET",
transform: (data) =>
data.filter((invite) =>
invite.roles.some((role) =>
["Agent", "Agent Manager", "System Manager"].includes(role)
)
),
});
const cancelInviteResource = createResource({
url: "frappe.core.api.user_invitation.cancel_invitation",
method: "PATCH",
onSuccess() {
toast.success("Invitation cancelled successfully");
toast.success(__("Invitation cancelled successfully"));
pendingInvitesResource.fetch();
},
});
@@ -162,29 +162,29 @@
</template>
<script setup lang="ts">
import { computed, nextTick, ref, useTemplateRef } from "vue";
import {
Avatar,
Button,
createDocumentResource,
FileUploader,
LoadingIndicator,
toast,
createDocumentResource,
} from "frappe-ui";
import { computed, nextTick, ref, useTemplateRef } from "vue";
import { __ } from "@/translation";
import { useAuthStore } from "@/stores/auth";
import { __ } from "@/translation";
import EditIcon from "~icons/lucide/edit";
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();
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
const showChangePasswordModal = ref(false);
const { userId, hasAgentRecord } = useAuthStore();
+5 -2
View File
@@ -1,11 +1,14 @@
import { ListResource } from "@/types";
import { HDAgent } from "@/types/doctypes";
import { createListResource, createResource } from "frappe-ui";
import { reactive, ref, watch } from "vue";
const PAGE_LENGTH = 20;
export const agents = createListResource({
export const agents: ListResource<HDAgent> = createListResource({
doctype: "HD Agent",
fields: ["name", "user_image", "agent_name", "is_active"],
fields: ["name", "user_image", "agent_name", "is_active", "user"],
cache: ["HD Agent", "List"],
start: 0,
pageLength: PAGE_LENGTH,
orderBy: "creation desc",
+43 -4
View File
@@ -4,11 +4,10 @@
:class="{ 'animate-pulse': loading }"
>
<slot name="number-cards">
<div
v-if="showVariant('number-cards')"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4"
>
<div v-if="showVariant('number-cards')" :class="numberCardsGridClass">
<!-- Vertical: title → value → subtitle stacked top→bottom -->
<div
v-if="orientation === 'vertical'"
v-for="i in numberCardsCount"
:key="`card-${i}`"
class="border rounded-md p-4 space-y-3 max-h-[140px] min-h-[114px]"
@@ -17,6 +16,30 @@
<div class="h-7 w-2/3 bg-surface-gray-1 rounded" />
<div class="h-3 w-1/2 bg-surface-gray-1 rounded" />
</div>
<!-- Horizontal: title on top, value + mini chart side-by-side, percentage row -->
<div
v-else
v-for="i in numberCardsCount"
:key="`card-h-${i}`"
class="border rounded-md flex flex-col p-4 pt-3 gap-2"
:style="{ minHeight: height }"
>
<div class="h-4 w-1/2 bg-surface-gray-3 rounded" />
<div class="flex items-end w-full gap-2 justify-between flex-1">
<div class="h-7 w-10 bg-surface-gray-2 rounded" />
<div
class="h-full max-w-[50%] w-[50%] flex items-end justify-end gap-1"
>
<div
v-for="(h, idx) in barHeights"
:key="idx"
:style="{ height: `${h}%` }"
class="w-2 bg-surface-gray-3 rounded-t-sm"
/>
</div>
</div>
<div class="h-3 w-1/3 bg-surface-gray-2 rounded" />
</div>
</div>
</slot>
<slot name="bar-chart">
@@ -115,6 +138,22 @@ const props = defineProps({
default: () => ["number-cards", "bar-chart"],
},
numberCardsCount: { type: Number, default: 5 },
// "vertical": title → value → subtitle stacked (default, KPI tiles).
// "horizontal": title on top, value + mini chart side-by-side, percentage row (compact chart cards).
orientation: {
type: String,
default: "vertical",
validator: (v) => ["vertical", "horizontal"].includes(v),
},
numberCardsGridClass: {
type: String,
default: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4",
},
// Min-height of each card in the horizontal number-cards layout (any CSS length).
height: {
type: String,
default: "160px",
},
barChartCount: { type: Number, default: 4 },
barHeights: { type: Array, default: () => [63, 95, 44, 37, 70] },
hasAppliedFilter: { type: Boolean, default: false },
+32
View File
@@ -0,0 +1,32 @@
<template>
<Autocomplete
:label="label ? __(label) : undefined"
:options="options"
:placeholder="__('Select timezone')"
:loading="timezoneResource.loading"
:model-value="modelValue"
@update:model-value="(v: { label: string; value: string } | string) => (modelValue = typeof v === 'string' ? v : v.value)"
/>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import { Autocomplete, createResource } from "frappe-ui";
import { computed } from "vue";
const props = defineProps<{
label?: string;
}>();
const modelValue = defineModel<string>({ default: "" });
const timezoneResource = createResource({
url: "frappe.core.doctype.user.user.get_timezones",
auto: true,
cache: ["Timezones"],
});
const options = computed<string[]>(
() => timezoneResource.data?.timezones ?? []
);
</script>
@@ -0,0 +1,59 @@
<template>
<div v-if="customers?.length" class="flex items-center gap-1.5">
<div class="flex items-center">
<Tooltip
v-for="customer in customers"
:key="customer.name"
:text="customer.name"
>
<Avatar
class="-mr-1.5 cursor-pointer ring-2 ring-[var(--surface-white)] transition hover:z-10 hover:scale-110"
shape="circle"
size="sm"
:image="customer.image"
:label="customer.name"
@click="goToCustomer(customer.name)"
/>
</Tooltip>
</div>
<span
class="text-sm text-ink-gray-8 ml-2"
:class="customers.length === 1 && 'cursor-pointer'"
v-on="
customers.length === 1
? { click: () => goToCustomer(customers[0]!.name) }
: {}
"
>
{{ label }}
</span>
</div>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import { Avatar, Tooltip } from "frappe-ui";
import { computed } from "vue";
import { useRouter } from "vue-router";
interface Customer {
name: string;
image?: string | null;
}
const props = defineProps<{
customers: Customer[];
}>();
const router = useRouter();
const label = computed(() =>
props.customers.length === 1
? props.customers[0].name
: __("{0} Customers", [props.customers.length])
);
function goToCustomer(name: string): void {
router.push({ name: "Customer", params: { id: name } });
}
</script>
@@ -0,0 +1,45 @@
<template>
<div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<LoadingIndicator :scale="10" />
</div>
<!-- Empty -->
<div
v-else-if="!feedbackCount.data"
class="flex flex-col items-center justify-center gap-3 py-16 text-center"
>
<LucideStar class="h-7.5 w-7.5 text-ink-gray-4" />
<div class="flex flex-col gap-1 max-w-[282px] m-auto">
<p class="text-base font-medium text-ink-gray-7">
{{ __("No feedback yet") }}
</p>
<p class="text-ink-gray-6 text-base">
{{
__("Feedback from this contact will appear here once available.")
}}
</p>
</div>
</div>
<!-- Content -->
<section v-else>
<FeedbackList :name="name" />
</section>
</div>
</template>
<script setup lang="ts">
import { useContactFeedback } from "@/composables/contact";
import { __ } from "@/translation";
import { LoadingIndicator } from "frappe-ui";
import LucideStar from "~icons/lucide/star";
import FeedbackList from "./FeedbackList.vue";
const props = defineProps<{
name: string;
}>();
const { feedbackCount, loading } = useContactFeedback(props.name);
</script>
@@ -0,0 +1,111 @@
<template>
<div class="relative gap-2 flex items-center w-full">
<component
:is="isPhone ? PhoneControl : FormControl"
ref="control"
class="flex-1"
:class="isPhone ? undefined : '[&_p]:text-p-xs'"
:type="isPhone ? undefined : type"
:placeholder="placeholder"
v-model="model"
@keydown.enter.prevent="blurInput"
>
<!-- <template #suffix>
</template> -->
</component>
<div class="flex items-center gap-0">
<Tooltip
v-if="isPrimary"
:text="canTogglePrimary ? __('Remove primary') : __('Primary')"
>
<Button
variant="ghost"
:aria-label="__('Remove primary')"
@click="$emit('setPrimary')"
>
<template #icon>
<LucideStar class="size-4 fill-ink-amber-2 stroke-ink-amber-2" />
</template>
</Button>
</Tooltip>
<Tooltip v-else :text="__('Set as primary')">
<Button
variant="ghost"
type="button"
:aria-label="__('Set as primary')"
@mousedown.prevent
@click="$emit('setPrimary')"
>
<template #icon>
<LucideStar class="size-4" />
</template>
</Button>
</Tooltip>
<Tooltip :text="__('Remove')">
<Button
:disabled="!canRemove"
variant="ghost"
type="button"
:aria-label="__('Remove')"
@mousedown.prevent
@click="$emit('remove')"
>
<template #icon>
<LucideX class="size-3.5" />
</template>
</Button>
</Tooltip>
</div>
</div>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import { Button, FormControl, Tooltip } from "frappe-ui";
import { computed, nextTick, onMounted, ref } from "vue";
import LucideStar from "~icons/lucide/star";
import LucideX from "~icons/lucide/x";
import PhoneControl from "../frappe-ui/PhoneControl/PhoneControl.vue";
const props = withDefaults(
defineProps<{
isPrimary: boolean;
canRemove: boolean;
canTogglePrimary?: boolean;
type?: "email" | "tel" | "text";
placeholder?: string;
autofocus?: boolean;
}>(),
{
canTogglePrimary: false,
type: "text",
placeholder: "",
autofocus: false,
}
);
const model = defineModel<string>({ required: true });
defineEmits<{
setPrimary: [];
remove: [];
}>();
const control = ref<any>(null);
const isPhone = computed(() => props.type === "tel");
function getInputEl(): HTMLInputElement | null {
return control.value?.$el?.querySelector?.("input") ?? null;
}
function blurInput() {
getInputEl()?.blur();
}
onMounted(() => {
if (props.autofocus) {
nextTick(() => getInputEl()?.focus());
}
});
</script>
@@ -0,0 +1,306 @@
<template>
<Dialog v-model:open="open" size="md" bare :dismissible="!isDirty">
<template #default>
<div class="bg-surface-modal px-4 py-5 sm:px-6">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ __("Edit Contact") }}
</h3>
<Badge
v-if="isDirty"
:label="__('Unsaved')"
variant="subtle"
theme="orange"
/>
</div>
<Button icon="x" variant="ghost" @click="open = false" />
</div>
<div class="space-y-4">
<!-- Avatar -->
<ImageAvatar
v-model="state.image"
:label="__('Photo')"
:fallback-label="
`${state.firstName} ${state.lastName}`.trim() || __('Contact')
"
shape="circle"
/>
<!-- First + Last name -->
<div class="grid grid-cols-2 gap-4">
<FormControl
class="[&_p]:text-p-xs"
:label="__('First Name')"
type="text"
:required="true"
:placeholder="__('John')"
v-model="state.firstName"
/>
<FormControl
class="[&_p]:text-p-xs"
:label="__('Last Name')"
type="text"
:placeholder="__('Doe')"
v-model="state.lastName"
/>
</div>
<!-- Email IDs -->
<div class="space-y-1.5">
<label class="block text-p-sm font-medium text-ink-gray-7">
{{ __("Email") }}
<span class="text-ink-red-3">*</span>
</label>
<ContactInputRow
v-for="(email, index) in state.emails"
:key="email.key"
v-model="email.email_id"
type="email"
placeholder="[email protected]"
:isPrimary="email.isPrimary"
:canRemove="!email.isPrimary"
:autofocus="email.key === autofocusKey"
@setPrimary="setPrimary('email', index)"
@remove="removeRow('email', index)"
/>
<Button
key="add-email"
size="sm"
:label="__('Add Email')"
@click="addRow('email')"
>
<template #prefix>
<LucidePlus class="size-3.5" />
</template>
</Button>
</div>
<!-- Phone numbers -->
<div class="space-y-1.5 flex flex-col items-start w-full flex-1">
<label class="block text-p-sm font-medium text-ink-gray-7">{{
__("Phone")
}}</label>
<ContactInputRow
v-for="(phone, index) in state.phones"
:key="phone.key"
v-model="phone.phone"
type="tel"
:isPrimary="phone.isPrimary"
:canRemove="true"
:canTogglePrimary="true"
:autofocus="phone.key === autofocusKey"
@setPrimary="setPrimary('phone', index)"
@remove="removeRow('phone', index)"
/>
<Button
key="add-phone"
size="sm"
:label="__('Add Phone')"
@click="addRow('phone')"
>
<template #prefix>
<LucidePlus class="size-3.5" />
</template>
</Button>
</div>
<!-- Timezone -->
<div v-if="doc.doc?.user" class="space-y-1.5">
<label class="block text-p-sm font-medium text-ink-gray-7">{{
__("Timezone")
}}</label>
<TimezoneControl v-model="state.timezone" />
</div>
<!-- Customer -->
<div class="space-y-1.5">
<label
class="block text-p-sm font-medium text-ink-gray-7"
v-if="!contactInfoResource.data?.invitation"
>
{{ __("Customer") }}
</label>
<!-- Already linked: read-only, opens the customer in a new tab -->
<div
v-for="customer in linkedCustomers"
:key="customer.name"
class="flex items-center gap-2"
>
<FormControl
class="flex-1"
type="text"
:modelValue="customer.name"
:disabled="true"
>
<template #prefix>
<Avatar
:label="customer.name"
:image="customer.image"
size="sm"
/>
</template>
</FormControl>
<Button
variant="ghost"
:tooltip="__('Open customer')"
@click="goToCustomer(customer.name)"
>
<template #icon>
<LucideExternalLink class="size-4 text-ink-gray-5" />
</template>
</Button>
</div>
<!-- Link a customer (only while none is linked yet) -->
<Link
v-if="canLinkCustomer"
v-model="state.customer"
doctype="HD Customer"
:placeholder="__('Select Customer')"
/>
</div>
<!-- Save -->
<div class="flex justify-end">
<Button
:label="__('Save')"
variant="solid"
theme="gray"
:disabled="!isDirty"
:loading="
editContactResource.loading || linkCustomerResource.loading
"
@click="handleSave"
/>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import Link from "@/components/frappe-ui/Link.vue";
import ImageAvatar from "@/components/ImageAvatar.vue";
import { nextEntryKey, useContact } from "@/composables/contact";
import { __ } from "@/translation";
import type { Error } from "@/types";
import { getErrorMessage } from "@/utils";
import { Avatar, Badge, Button, Dialog, FormControl } from "frappe-ui";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import LucideExternalLink from "~icons/lucide/external-link";
import LucidePlus from "~icons/lucide/plus";
import TimezoneControl from "../TimezoneControl.vue";
import ContactInputRow from "./ContactInputRow.vue";
const props = defineProps<{ name: string }>();
const open = defineModel<boolean>({ default: false });
const {
state,
parseContactData,
isDirty,
isContactDirty,
editContactResource,
linkCustomerResource,
doc,
contactInfoResource,
} = useContact(props.name);
const router = useRouter();
const autofocusKey = ref<number | null>(null);
const linkedCustomers = computed<{ name: string; image?: string | null }[]>(
() => contactInfoResource.data?.customers ?? []
);
const canLinkCustomer = computed(
() =>
linkedCustomers.value.length === 0 && !contactInfoResource.data?.invitation
);
function goToCustomer(name: string) {
const route = router.resolve({ name: "Customer", params: { id: name } });
window.open(route.href, "_blank");
}
function addRow(type: "email" | "phone") {
const key = nextEntryKey();
if (type === "email") {
state.emails.push({
email_id: "",
isPrimary: state.emails.length === 0,
key,
});
} else {
state.phones.push({
phone: "",
isPrimary: false,
key,
});
}
autofocusKey.value = key;
}
function removeRow(type: "email" | "phone", index: number) {
const list = type === "email" ? state.emails : state.phones;
const wasPrimary = list[index]?.isPrimary;
list.splice(index, 1);
if (type === "email") {
const first = list[0];
if (wasPrimary && first) {
first.isPrimary = true;
}
}
}
function setPrimary(type: "email" | "phone", index: number) {
if (type === "email") {
state.emails.forEach((e, i) => {
e.isPrimary = i === index;
});
} else {
const wasPrimary = Boolean(state.phones[index]?.isPrimary);
state.phones.forEach((p, i) => {
p.isPrimary = i === index ? !wasPrimary : false;
});
}
}
async function handleSave() {
const tasks: Promise<unknown>[] = [];
if (isContactDirty.value) {
tasks.push(
editContactResource.submit({ name: props.name, doc: parseContactData() })
);
}
if (state.customer) {
tasks.push(linkCustomerResource.submit());
}
if (!tasks.length) return;
try {
await Promise.all(tasks);
state.customer = "";
open.value = false;
doc.reload();
contactInfoResource.reload();
} catch (error) {
getErrorMessage(error as Error, true);
}
}
</script>
<style scoped>
.row-leave-active {
position: absolute;
width: 100%;
transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1),
transform 180ms cubic-bezier(0.2, 0, 0, 1);
}
.row-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
@@ -0,0 +1,184 @@
<template>
<div class="flex-1 min-w-0">
<!-- Header: title + sort tabs -->
<div class="flex items-center justify-between pb-4">
<h4 class="font-semibold text-ink-gray-8">
{{ __("User Reviews") }}
</h4>
<TabButtons
v-model="activeSort"
:buttons="sortOptions"
@update:modelValue="onSortChange"
/>
</div>
<!-- Loading -->
<div v-if="feedbackListResource.loading" class="flex justify-center py-10">
<LoadingIndicator :scale="10" />
</div>
<!-- Empty state -->
<div
v-else-if="!feedbackListResource.data?.length"
class="flex flex-col items-center justify-center gap-3 py-16 text-center h-full flex-1"
>
<LucideMessageSquare class="h-10 w-10 text-ink-gray-4" />
<div>
<p class="text-lg font-medium text-ink-gray-7">
{{ __("No reviews found") }}
</p>
</div>
</div>
<!-- List -->
<div v-else class="flex flex-col gap-2 max-h-[65vh] overflow-y-auto">
<template v-for="ticket in feedbackListResource.data" :key="ticket.name">
<div
class="flex cursor-pointer flex-col gap-2 rounded-md border border-outline-gray-1 bg-surface-white p-2.5 hover:bg-surface-gray-1"
@click="goToTicket(ticket.name)"
>
<!-- Rating badge + ticket name -->
<div class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center gap-1 rounded-sm px-1.5 py-1 text-sm"
:class="ratingBadgeClass(ticket.feedback_rating)"
>
<LucideStar class="size-3 fill-current" />
{{ formatRating(ticket.feedback_rating) }}
</span>
<p class="min-w-0 flex-1 truncate text-sm">
<span class="text-ink-gray-5"># {{ ticket.name }}</span>
<span class="ml-1 font-medium text-ink-gray-7">{{
ticket.subject
}}</span>
</p>
<Tooltip :text="dayjs(ticket.modified).format('LLL')">
<span class="shrink-0 text-xs text-ink-gray-5">
{{ dayjs(ticket.modified).fromNow() }}
</span>
</Tooltip>
</div>
<!-- Feedback title + body -->
<div class="flex flex-col gap-1.5">
<p
v-if="ticket.feedback"
class="text-sm font-medium text-ink-gray-7"
>
{{ ticket.feedback }}
</p>
<p v-if="ticket.feedback_extra" class="text-p-base text-ink-gray-6">
{{ ticket.feedback_extra }}
</p>
</div>
</div>
</template>
<!-- Load More -->
<div
class="flex justify-center py-6"
v-if="feedbackListResource.hasNextPage"
>
<Button
:loading="feedbackListResource.loading"
:label="__('Load More')"
icon-left="refresh-cw"
@click="feedbackListResource.next()"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useContactFeedback } from "@/composables/contact";
import { __ } from "@/translation";
import { dayjs, LoadingIndicator, TabButtons, Tooltip } from "frappe-ui";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import LucideMessageSquare from "~icons/lucide/message-square";
import LucideStar from "~icons/lucide/star";
const props = defineProps<{
name: string;
}>();
const { feedbackListResource, feedbackCount } = useContactFeedback(props.name);
const router = useRouter();
function goToTicket(name: string) {
const route = router.resolve({
name: "TicketAgent",
params: { ticketId: String(name) },
});
window.open(route.href, "_blank");
}
type SortValue = "all" | "latest" | "positive" | "negative";
const sortOptions: { label: string; value: SortValue }[] = [
{ label: __("All"), value: "all" },
{ label: __("Latest"), value: "latest" },
{ label: __("Positive"), value: "positive" },
{ label: __("Negative"), value: "negative" },
];
const activeSort = ref<SortValue>("all");
type FilterList = [string, string, unknown][];
interface TabConfig {
filters: Record<string, unknown> | FilterList;
orderBy?: string;
}
function tabConfig(value: SortValue): TabConfig {
const base = { contact: props.name };
switch (value) {
case "all":
return { filters: { ...base, feedback_rating: ["is", "set"] } };
case "latest":
return {
filters: { ...base, feedback_rating: ["is", "set"] },
orderBy: "creation desc",
};
case "positive":
return {
filters: { ...base, feedback_rating: [">", 0.6] },
orderBy: "feedback_rating desc",
};
case "negative":
return {
filters: [
["contact", "=", props.name],
["feedback_rating", "is", "set"],
["feedback_rating", "<=", 0.6],
],
orderBy: "feedback_rating asc",
};
}
}
function onSortChange(value: SortValue) {
const { filters, orderBy } = tabConfig(value);
feedbackListResource.update({ filters, orderBy });
feedbackListResource.reload();
}
function formatRating(rating: number | undefined | null): number {
if (!rating) return 0;
return Number(rating) * 5;
}
function ratingBadgeClass(rating: number | undefined | null): string {
const stars = formatRating(rating);
if (stars >= 4) return "bg-green-100 text-green-700";
if (stars >= 2.5) return "bg-yellow-100 text-yellow-700";
return "bg-red-100 text-red-700";
}
onMounted(() => {
onSortChange(activeSort.value);
});
</script>
@@ -0,0 +1,134 @@
<template>
<div>
<Dialog
v-model:open="open"
:title="__('Create Contact')"
size="md"
@after-leave="reset"
>
<template #default>
<div class="space-y-4">
<template
v-for="fieldOrRow in fieldConfig"
:key="
Array.isArray(fieldOrRow) ? fieldOrRow[0]?.key : fieldOrRow.key
"
>
<!-- Grid row (e.g. first + last name) -->
<div
v-if="Array.isArray(fieldOrRow)"
class="grid grid-cols-2 gap-4"
>
<template v-for="field in fieldOrRow" :key="field.key">
<FormControl
class="[&_p]:text-p-xs"
:label="field.label"
:type="field.type"
:required="field.required"
:placeholder="field.placeholder"
v-model="(state as any)[field.key]"
>
<template v-if="field.prefix" #prefix>
<component :is="field.prefix" />
</template>
</FormControl>
</template>
</div>
<!-- File: avatar + uploader -->
<template v-else-if="fieldOrRow.type === 'file'">
<ImageAvatar
v-model="state.image"
:label="__('Photo')"
:fallback-label="state.firstName || __('Contact')"
shape="circle"
/>
</template>
<!-- Email: single flat field -->
<template v-else-if="fieldOrRow.type === 'email'">
<FormControl
class="[&_p]:text-p-xs"
:label="fieldOrRow.label"
type="email"
:required="fieldOrRow.required"
:placeholder="fieldOrRow.placeholder"
v-model="state.email"
>
<template #prefix>
<LucideMail class="size-4" />
</template>
</FormControl>
</template>
<!-- Phone (tel): single flat field -->
<template v-else-if="fieldOrRow.type === 'tel'">
<PhoneControl :label="fieldOrRow.label" v-model="state.phone" />
</template>
<template v-else-if="fieldOrRow.type === 'Link'">
<div class="space-y-1.5">
<Link
:doctype="fieldOrRow.doctype!"
:placeholder="fieldOrRow.placeholder"
v-model="state.customer"
:label="fieldOrRow.label"
/>
</div>
</template>
<!-- Default: text / number / etc -->
<template v-else>
<FormControl
class="[&_p]:text-p-xs"
:label="fieldOrRow.label"
:type="fieldOrRow.type"
:required="fieldOrRow.required"
:placeholder="fieldOrRow.placeholder"
v-model="(state as any)[fieldOrRow.key]"
>
<template v-if="fieldOrRow.prefix" #prefix>
<component :is="fieldOrRow.prefix" />
</template>
</FormControl>
</template>
</template>
<Checkbox
size="sm"
v-model="state.invite"
:label="__('Invite as User')"
/>
<div class="flex justify-end pt-1 gap-2">
<Button
:label="__('Create')"
theme="gray"
variant="solid"
@click="handleAdd"
:loading="isLoading"
/>
</div>
</div>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import ImageAvatar from "@/components/ImageAvatar.vue";
import { useNewContact } from "@/composables/contact";
import { __ } from "@/translation";
import { Button, Checkbox, Dialog, FormControl } from "frappe-ui";
import LucideMail from "~icons/lucide/mail";
import Link from "../frappe-ui/Link.vue";
import PhoneControl from "../frappe-ui/PhoneControl/PhoneControl.vue";
const open = defineModel<boolean>({ default: false });
const { state, fieldConfig, addContact, isLoading, reset } = useNewContact();
function handleAdd() {
addContact();
}
</script>
@@ -0,0 +1,324 @@
<template>
<div
class="rounded-md bg-surface-white border border-outline-gray-1 px-3 py-2.5 flex flex-col gap-2.5 hover:border-outline-gray-3 hover:cursor-pointer"
@click="goToContact"
>
<div class="flex items-center justify-between">
<div class="flex gap-2 items-center min-w-0">
<Avatar
size="lg"
:shape="'circle'"
:image="contact.image ?? ''"
:label="contact.contact_name"
/>
<p
class="truncate min-w-0"
:class="contact.is_manager ? 'max-w-[50%]' : ''"
>
{{ contact.contact_name }}
</p>
<Tooltip
v-if="contact.is_manager"
:text="__('Can view tickets raised by all contacts of the customer.')"
placement="top"
>
<Badge :label="__('Manager')" theme="green" variant="outline" />
</Tooltip>
</div>
<div class="flex gap-1">
<Tooltip
v-if="contact.is_primary"
:text="__('Primary')"
placement="top"
>
<span
class="flex h-6 w-6 items-center justify-center text-ink-amber-2"
:aria-label="__('Primary')"
>
<LucideStar class="size-4 fill-ink-amber-2" />
</span>
</Tooltip>
<Dropdown
v-if="hasPermission()"
placement="right"
:options="dropdownOptions"
>
<Button
class="h-6 w-6 p-0 min-w-fit shrink-0"
variant="ghost"
@click.stop
>
<LucideMoreHorizontal class="h-4 w-4 text-ink-gray-6" />
</Button>
</Dropdown>
</div>
</div>
<div class="border-t border-gray-200 w-full" />
<div class="flex flex-col gap-3">
<template v-for="item in contactDetails" :key="item.value">
<div
class="flex items-center gap-2 text-sm text-ink-gray-8 font-[420]"
:class="item.class?.(item.value)"
>
<component
:is="item.icon"
class="h-4 w-4 text-ink-gray-6"
:class="item.color?.(item.value)"
/>
<span
class="text-ink-gray-8"
:class="[item.class?.(item.value), item.color?.(item.value)]"
>
{{ item.value }}
</span>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { globalStore } from "@/stores/globalStore";
import { __ } from "@/translation";
import { CustomerContact, CustomerResourceSymbol } from "@/types";
import { HDCustomerMember } from "@/types/doctypes";
import { getErrorMessage, hasPermission } from "@/utils";
import {
Avatar,
Badge,
Button,
Dropdown,
Tooltip,
dayjs,
toast,
} from "frappe-ui";
import { computed, inject, markRaw } from "vue";
import { useRouter } from "vue-router";
import LucideMail from "~icons/lucide/mail";
import LucideMoreHorizontal from "~icons/lucide/more-horizontal";
import LucidePhone from "~icons/lucide/phone";
import LucideStar from "~icons/lucide/star";
import LucideTicket from "~icons/lucide/ticket";
import ModifiedIcon from "../icons/ModifiedIcon.vue";
const props = defineProps<{
contact: CustomerContact;
}>();
const emit = defineEmits(["update"]);
const router = useRouter();
const { $dialog } = globalStore();
const customer = inject(CustomerResourceSymbol)!;
const ticketCountLabel = computed(() => {
const count = props.contact.ticket_count;
if (count === 0) return __("No active tickets");
return `${count} ${count === 1 ? __("active ticket") : __("active tickets")}`;
});
function goToContact() {
router.push({ name: "Contact", params: { id: props.contact.contact_name } });
}
const contactDetails = computed(() => [
{
icon: markRaw(LucideMail),
value: props.contact.email_id || "-",
},
{
icon: markRaw(LucidePhone),
value: props.contact.mobile_no || "-",
},
{
icon: markRaw(ModifiedIcon),
value: `Last seen ${
props.contact.last_active
? dayjs(props.contact.last_active).fromNow()
: "Never"
}`,
},
{
icon: markRaw(LucideTicket),
value: ticketCountLabel.value,
color: (value: string) =>
value !== __("No active tickets")
? "!text-amber-700"
: "!text-ink-gray-4",
class: (value: string) =>
value !== __("No active tickets") ? "hover:underline cursor-pointer" : "",
},
]);
const dropdownOptions = computed(() => {
const primaryActions = [];
if (!props.contact.is_primary) {
primaryActions.push({
label: __("Set as Primary"),
icon: "star",
onClick: () => {
updatePrimaryContact();
},
});
}
const roleActions = [
{
label: __("Role"),
icon: "briefcase",
submenu: [
{
label: __("Customer"),
icon: props.contact.is_manager ? undefined : "check",
onClick: () => {
if (!props.contact.is_manager) return;
updateManagerRole(0);
},
},
{
label: __("Customer Manager"),
icon: props.contact.is_manager ? "check" : undefined,
onClick: () => {
if (props.contact.is_manager) return;
updateManagerRole(1);
},
},
],
},
];
const destructiveActions = [
{
label: __("Remove Contact"),
icon: "x",
theme: "red" as const,
onClick: () => {
removeContact();
},
},
];
const deleteActionGroup = {
group: "",
hideLabel: true,
items: destructiveActions,
};
if (props.contact.is_primary) {
return [deleteActionGroup];
}
return [
{
group: "",
hideLabel: true,
items: [...primaryActions, ...roleActions],
},
deleteActionGroup,
];
});
function updateManagerRole(isManager: 0 | 1) {
const title = isManager
? __("Grant Manager Access")
: __("Revoke Manager Access");
const message = isManager
? __(
"They'll get access to tickets raised by everyone in the organisation."
)
: __("They'll only see their own tickets going forward.");
$dialog({
title,
message,
actions: [
{
label: __("Confirm"),
variant: "solid",
onClick: ({ close }: { close: () => void }) => {
const contact = customer.doc.contacts?.find(
(c) => c.contact_name === props.contact.contact_name
) as HDCustomerMember | undefined;
if (!contact) return;
contact.is_manager = isManager;
customer.setValue.submit(
{ contacts: customer.doc.contacts },
{
onSuccess() {
emit("update");
close();
toast.success(__("Role updated successfully"));
},
onError(error: any) {
getErrorMessage(error, true);
},
}
);
},
},
],
});
}
function updatePrimaryContact() {
if (customer.doc.primary_contact === props.contact.contact_name) return;
$dialog({
title: __("Set Primary Contact"),
message: __(
"This contact will become the primary point of contact and will be able to view tickets raised by all other contacts in the organisation."
),
actions: [
{
label: __("Confirm"),
variant: "solid",
onClick: ({ close }: { close: () => void }) =>
customer.setValue.submit(
{ primary_contact: props.contact.contact_name },
{
onSuccess() {
emit("update");
close();
toast.success(__("Primary contact updated"));
},
onError(error: any) {
getErrorMessage(error, true);
},
}
),
},
],
});
}
function removeContact() {
$dialog({
title: __("Remove Contact"),
message: __(
"Are you sure you want to remove this contact from the customer?"
),
actions: [
{
label: __("Confirm"),
variant: "solid",
onClick: ({ close }: { close: () => void }) =>
customer.setValue.submit(
{
contacts: customer.doc.contacts?.filter(
(c) => c.contact_name !== props.contact.contact_name
),
},
{
onSuccess() {
emit("update");
close();
toast.success(__("Contact removed successfully"));
},
onError(error: any) {
getErrorMessage(error, true);
},
}
),
},
],
});
}
</script>
@@ -0,0 +1,81 @@
<template>
<div>
<div class="flex items-center justify-between pb-4">
<p class="font-semibold text-lg">{{ __("Contacts") }}</p>
<Button
v-if="hasPermission()"
:label="__('Invite')"
variant="subtle"
@click="showInviteContact = true"
>
<template #prefix>
<LucidePlus class="h-4 w-4" />
</template>
</Button>
</div>
<!-- Loading -->
<div v-if="customer.getContacts?.loading" class="flex justify-center py-10">
<LoadingIndicator :scale="10" />
</div>
<!-- Empty state -->
<div
v-else-if="!customer.getContacts?.data?.length"
class="flex flex-col items-center justify-center gap-3 py-16 text-center h-full flex-1"
>
<LucideUserX class="h-10 w-10 text-ink-gray-4" />
<div>
<!-- make font larger -->
<p class="text-lg font-medium text-ink-gray-7">
{{ __("No contacts found") }}
</p>
</div>
</div>
<!-- Contacts list -->
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
<ContactCard
v-for="contact in customer.getContacts?.data"
:key="contact.contact_name"
:contact="contact"
@update="customer.getContacts?.reload()"
/>
</div>
</div>
<InviteContactDialog
v-model="showInviteContact"
:excluded-emails="existingContacts"
/>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import { CustomerResourceSymbol } from "@/types";
import { hasPermission } from "@/utils";
import { Button, LoadingIndicator } from "frappe-ui";
import { computed, inject, onMounted, ref } from "vue";
import LucidePlus from "~icons/lucide/plus";
import LucideUserX from "~icons/lucide/user-x";
import { agents } from "../Settings/agents";
import ContactCard from "./ContactCard.vue";
import InviteContactDialog from "./InviteContactDialog.vue";
const customer = inject(CustomerResourceSymbol)!;
const showInviteContact = ref(false);
const existingContacts = computed(() => {
if (agents.loading && customer.getContacts?.loading) return [];
if (!agents.data && !customer.getContacts?.data) return [];
return [
...new Set([
...(agents.data?.map((a) => a.user) || []),
...(customer.getContacts?.data?.map((c) => c.email_id) || []),
"Guest",
]),
];
});
onMounted(() => {
agents.fetch();
});
</script>
@@ -0,0 +1,175 @@
<template>
<Dialog
v-model:open="model"
size="md"
bare
:dismissible="!isDirty"
@after-leave="revertChanges"
>
<template #default>
<div class="bg-surface-modal px-4 pb-6 py-5 sm:px-6">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
Edit Customer
</h3>
<Badge
v-if="isDirty"
:label="__('Unsaved')"
variant="subtle"
theme="orange"
/>
</div>
<Button icon="x" @click="model = false" variant="ghost" />
</div>
<div class="space-y-4">
<!-- Image section -->
<ImageAvatar
v-model="state.image"
:label="__('Logo')"
:fallback-label="state.name || __('Customer')"
shape="square"
/>
<!-- Fields -->
<template v-for="field in customerFields" :key="field.key">
<FormControl
class="!text-ink-gray-8"
:class="[
'[&_p]:text-p-xs',
field.type === 'select' &&
'[&_[data-slot=trigger]]:w-full [&_[data-slot=trigger]]:!text-ink-gray-8 ',
]"
v-if="field.type !== 'Link'"
:type="field.type"
:label="field.label"
:required="field.required"
:placeholder="field.placeholder"
:description="field.description"
:options="field.options"
v-model="state[field.key]"
>
<template v-if="field.prefix" #prefix>
<component :is="field.prefix" />
</template>
</FormControl>
<Link
v-else
:label="field.label"
:placeholder="field.placeholder"
:description="field.description"
:doctype="field.doctype"
:required="field.required"
v-model="state[field.key]"
>
<template v-if="field.prefix" #prefix>
<component :is="field.prefix" />
</template>
</Link>
</template>
<div class="float-right flex space-x-2 pb-5">
<Button
:label="__('Save')"
theme="gray"
variant="solid"
:loading="loading || customer.setValue.loading"
@click.prevent="save"
/>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import ImageAvatar from "@/components/ImageAvatar.vue";
import { customerFields, useCustomer } from "@/composables/customer";
import { __ } from "@/translation";
import { getErrorMessage } from "@/utils.ts";
import { Badge, Button, call, Dialog, FormControl, toast } from "frappe-ui";
import { ref } from "vue";
import { useRouter } from "vue-router";
import Link from "../frappe-ui/Link.vue";
const model = defineModel<boolean>({ default: false });
const emit = defineEmits(["update"]);
const router = useRouter();
const id = router.currentRoute.value.params.id as string;
const {
doc: customer,
state,
isDirty,
hasNameChanged,
isCustomerInfoChanged,
} = useCustomer(id);
const loading = ref(false);
async function save() {
if (!isDirty.value) {
return;
}
loading.value = true;
try {
if (hasNameChanged.value) {
if (!state.name.trim()) {
toast.error(__("Customer name cannot be empty"));
return;
}
const newName = await callRenameDoc();
if (isCustomerInfoChanged.value) {
// Use raw call after rename since the `createDocumentResource` still holds the old name
await call("frappe.client.set_value", {
doctype: "HD Customer",
name: newName,
fieldname: {
domain: state.domain,
country: state.country,
image: state.image,
customer_type: state.customerType,
},
});
}
toast.success(__("Customer updated"));
await router.replace({ name: "Customer", params: { id: newName } });
window.location.reload();
return;
}
if (isCustomerInfoChanged.value) {
await customer.setValue.submit({
name: state.name,
domain: state.domain,
country: state.country,
image: state.image,
customer_type: state.customerType,
});
}
toast.success(__("Customer updated"));
emit("update");
} catch (error: any) {
let msg = getErrorMessage(error);
toast.error(__(msg));
} finally {
loading.value = false;
}
}
async function callRenameDoc() {
return call("frappe.client.rename_doc", {
doctype: "HD Customer",
old_name: customer.doc.customer_name,
new_name: state.name,
});
}
function revertChanges() {
if (!isDirty.value) return;
state.name = customer.doc.customer_name || "";
state.domain = customer.doc.domain || "";
state.country = customer.doc.country || "";
state.image = customer.doc.image || "";
state.customerType = customer.doc.customer_type || "Company";
}
</script>
@@ -0,0 +1,191 @@
<template>
<Dialog v-model:open="show" :title="__('Invite Contact')">
<template #default>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<div
class="p-1 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded w-full"
>
<EmailMultiSelect
ref="emailMultiSelect"
inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
:placeholder="__('Enter email address')"
:existingUsers="excludedEmails"
:additionalFilters="contactFilters"
scope="contact"
allowCustomEmail
:validate="validateEmailWithZod"
v-model="selectedContacts"
:emptyPlaceholder="__('Type an email address')"
/>
</div>
<div class="flex items-center gap-1.5 text-p-sm text-ink-gray-6">
<span>
{{ __("Existing users join instantly. Others get an invite.") }}
</span>
</div>
</div>
<FormControl
type="select"
class="w-full [&>button]:w-full"
v-model="role"
:label="__('Role')"
:options="roleOptions"
:description="roleDescription"
/>
<div v-if="pendingInvites.length" class="flex flex-col gap-3 mt-2">
<p class="text-base font-medium text-ink-gray-5">
{{ __("Pending Invites") }}
</p>
<div class="flex flex-col gap-3 max-h-60 overflow-y-auto">
<div
v-for="invite in pendingInvites"
:key="invite.name"
class="flex items-center justify-between gap-2 min-w-0"
>
<div class="flex items-center gap-2 min-w-0">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-full border border-dashed border-outline-gray-3"
>
<LucideUser class="size-4 text-ink-gray-5" />
</div>
<div class="flex flex-col gap-0.5 min-w-0">
<p class="text-base font-medium text-ink-gray-8 truncate">
{{ invite.email }}
</p>
<p class="text-sm text-ink-gray-5 truncate">
{{ __("Invited by") }} {{ invite.invited_by }} ·
{{ dayjsLocal(invite.invited_on).fromNow() }}
</p>
</div>
</div>
<div class="flex justify-end min-w-[7rem] shrink-0">
<RevokeInviteButton
:invite-name="invite.name"
@revoked="customer.getPendingInvites.reload()"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<template #actions>
<div class="flex justify-end gap-2">
<Button
variant="solid"
:label="__('Invite')"
:loading="customer.addContacts.loading"
:disabled="!selectedContacts.length"
@click="inviteContacts"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import RevokeInviteButton from "@/components/customer/RevokeInviteButton.vue";
import EmailMultiSelect from "@/components/EmailMultiSelect.vue";
import { __ } from "@/translation";
import { CustomerResourceSymbol } from "@/types";
import {
getErrorMessage,
handleInviteUserSuccess,
validateEmailWithZod,
} from "@/utils";
import { Button, Dialog, FormControl, dayjsLocal, toast } from "frappe-ui";
import { computed, inject, ref, watch } from "vue";
import LucideUser from "~icons/lucide/user";
const props = defineProps<{
excludedEmails: string[];
}>();
const show = defineModel<boolean>({ default: false });
const customer = inject(CustomerResourceSymbol)!;
const emailMultiSelect = ref<{ setFocus: () => void } | null>(null);
const selectedContacts = ref<string[]>([]);
const role = ref("HD Customer");
interface PendingInvite {
name: string;
email: string;
roles: string[];
invited_by: string;
invited_on: string;
}
interface InviteResult {
invited_emails: string[];
pending_invite_emails: string[];
accepted_invite_emails: string[];
disabled_user_emails: string[];
}
const roleOptions = [
{ value: "HD Customer", label: __("Customer") },
{ value: "HD Customer Manager", label: __("Customer Manager") },
];
const roleDescriptions: Record<string, string> = {
"HD Customer": __(
"Can raise tickets on behalf of the org and manage the tickets they raised."
),
"HD Customer Manager": __(
"Can view all tickets raised by the org and assign other members as managers."
),
};
const roleDescription = computed(() => roleDescriptions[role.value] ?? "");
const pendingInvites = computed<PendingInvite[]>(
() => customer.getPendingInvites.data || []
);
const contactFilters = computed(() => {
const filters: (string | string[])[][] = [["Contact", "user", "is", "set"]];
if (props.excludedEmails.length) {
filters.push(["Contact", "email_id", "not in", props.excludedEmails]);
}
return filters;
});
function inviteContacts() {
customer.addContacts.submit(
{
contacts: JSON.stringify(selectedContacts.value),
role: role.value,
},
{
onSuccess() {
const data: { added: string[]; invite_result: InviteResult } =
customer.addContacts.data;
selectedContacts.value = [];
if (data.added.length) {
toast.success(__("Contacts added successfully"));
customer.getContacts.reload();
}
if (data.invite_result.invited_emails.length) {
customer.getPendingInvites.reload();
}
handleInviteUserSuccess(data.invite_result);
},
onError(error: any) {
getErrorMessage(error, true);
},
}
);
}
watch(show, (val) => {
if (!val) {
selectedContacts.value = [];
return;
}
customer.getPendingInvites.reload();
setTimeout(() => emailMultiSelect.value?.setFocus(), 100);
});
</script>
@@ -0,0 +1,200 @@
<template>
<Dialog
v-model:open="model"
:title="__('Create Customer')"
size="lg"
@after-leave="reset"
>
<template #default>
<div>
<div class="flex flex-col gap-3">
<ImageAvatar
v-model="state.image"
:label="__('Logo')"
:fallback-label="state.name || __('Customer')"
shape="square"
/>
<FormControl
class="[&_p]:text-p-xs"
type="text"
:label="__('Name')"
:required="true"
placeholder="Frappe"
v-model="state.name"
/>
<div class="grid grid-cols-2 gap-4">
<FormControl
class="[&_p]:text-p-xs [&_[data-slot=trigger]]:w-full [&_[data-slot=trigger]]:!text-ink-gray-8"
type="select"
:label="__('Customer Type')"
:options="customerTypeOptions"
v-model="state.customerType"
/>
<Link
:label="__('Country')"
placeholder="India"
doctype="Country"
v-model="state.country"
>
<template #prefix>
<LucideMapPin class="size-4 mr-1.5" />
</template>
</Link>
</div>
<FormControl
class="[&_p]:text-p-xs"
type="text"
:label="__('Domain')"
placeholder="frappe.io"
v-model="state.domain"
>
<template #prefix>
<LucideGlobe class="size-4" />
</template>
</FormControl>
</div>
<hr class="my-5 border-outline-gray-2" />
<div class="flex flex-col gap-3">
<h3 class="text-base font-medium text-ink-gray-8">
{{ __("Primary Contact") }}
</h3>
<div class="grid grid-cols-2 gap-4">
<FormControl
class="[&_p]:text-p-xs"
type="text"
:label="__('First Name')"
placeholder="John"
v-model="primaryContact.firstName"
/>
<FormControl
class="[&_p]:text-p-xs"
type="text"
:label="__('Last Name')"
placeholder="Doe"
v-model="primaryContact.lastName"
/>
</div>
<FormControl
class="[&_p]:text-p-xs"
type="email"
:label="__('Email')"
placeholder="[email protected]"
v-model="primaryContact.email"
>
<template #prefix>
<LucideMail class="size-4" />
</template>
</FormControl>
<PhoneControl
:label="__('Mobile Number')"
v-model="primaryContact.mobileNo"
/>
</div>
</div>
</template>
<template #actions>
<div class="flex justify-end gap-2">
<Button
:label="__('Create')"
theme="gray"
variant="solid"
:loading="customerResource.loading"
@click.prevent="addCustomer"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import ImageAvatar from "@/components/ImageAvatar.vue";
import {
customerTypeOptions,
useNewCustomerForm,
} from "@/composables/customer";
import { __ } from "@/translation";
import type { Resource } from "@/types";
import { getErrorMessage, validateEmailWithZod } from "@/utils";
import { Button, Dialog, FormControl, createResource, toast } from "frappe-ui";
import { useRouter } from "vue-router";
import LucideGlobe from "~icons/lucide/globe";
import LucideMail from "~icons/lucide/mail";
import LucideMapPin from "~icons/lucide/map-pin";
import Link from "../frappe-ui/Link.vue";
import PhoneControl from "../frappe-ui/PhoneControl/PhoneControl.vue";
const model = defineModel<boolean>({ default: false });
const router = useRouter();
const { state, primaryContact, reset } = useNewCustomerForm();
const customerResource: Resource = createResource({
url: "helpdesk.api.customer.create_customer",
method: "POST",
onSuccess: (name: string) => {
router.push({ name: "Customer", params: { id: name } });
toast.success(__("Customer created"));
},
onError: (error: unknown) => {
getErrorMessage(error, true);
},
});
function addCustomer() {
if (!state.name) {
toast.error(__("Name is required"));
return;
}
if (!validatePrimaryContact()) return;
customerResource.submit({
customer: {
customer_name: state.name,
customer_type: state.customerType,
domain: state.domain,
image: state.image,
country: state.country,
},
primary_contact: hasPrimaryContactData()
? {
first_name: primaryContact.firstName,
last_name: primaryContact.lastName,
email: primaryContact.email,
mobile_no: primaryContact.mobileNo,
}
: null,
});
}
function validatePrimaryContact(): boolean {
if (!hasPrimaryContactData()) return true;
if (!primaryContact.firstName) {
toast.error(__("Enter the primary contact's first name"));
return false;
}
if (!primaryContact.email) {
toast.error(__("Enter the primary contact's email"));
return false;
}
if (!validateEmailWithZod(primaryContact.email)) {
toast.error(__("Invalid email address"));
return false;
}
return true;
}
function hasPrimaryContactData(): boolean {
return Boolean(
primaryContact.firstName ||
primaryContact.lastName ||
primaryContact.email ||
primaryContact.mobileNo
);
}
</script>
@@ -0,0 +1,75 @@
<template>
<div
class="invite-revoke-button"
:class="{ 'invite-revoke-button--expanded': confirming }"
>
<Button
:variant="confirming ? 'subtle' : 'ghost'"
:theme="confirming ? 'red' : 'gray'"
:icon="!confirming && 'trash-2'"
:icon-left="confirming && 'trash-2'"
:label="confirming && __('Confirm')"
:tooltip="__('Revoke Invite')"
:loading="cancelInviteResource.loading"
class="invite-revoke-button__control"
@click="confirming ? revoke() : (confirming = true)"
/>
</div>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import { getErrorMessage } from "@/utils";
import { Button, createResource, toast } from "frappe-ui";
import { ref } from "vue";
const props = defineProps<{
inviteName: string;
}>();
const emit = defineEmits<{
revoked: [];
}>();
const confirming = ref(false);
const cancelInviteResource = createResource({
url: "frappe.core.api.user_invitation.cancel_invitation",
method: "PATCH",
onSuccess() {
toast.success(__("Invitation cancelled successfully"));
confirming.value = false;
emit("revoked");
},
onError(error: any) {
confirming.value = false;
getErrorMessage(error, true);
},
});
function revoke() {
cancelInviteResource.submit({ name: props.inviteName, app_name: "helpdesk" });
}
</script>
<style scoped>
.invite-revoke-button {
width: 1.75rem;
margin-left: auto;
overflow: hidden;
transition: width 0.22s ease;
}
.invite-revoke-button--expanded {
width: 6.75rem;
}
.invite-revoke-button :deep(button) {
width: 100%;
min-width: 100%;
justify-content: center;
white-space: nowrap;
transition: background-color 0.22s ease, color 0.22s ease,
border-color 0.22s ease;
}
</style>
@@ -0,0 +1,107 @@
<template>
<div class="p-5 pt-0 pb-2">
<div
v-if="!analytics.loading && analytics.data"
class="grid grid-cols-4 gap-2.5 min-h-[130px]"
>
<div
v-for="(chartData, key) in analytics.data"
class="h-full w-full rounded border border-outline-gray-1"
>
<BarChartCard
v-if="key === 'feedback_received'"
:title="__('Avg. Feedback Received')"
:data="chartData"
bar-color="#E79913"
measure="average"
:timelineFilter="false"
:dt="props.dt"
:dn="props.dn"
orientation="horizontal"
:negativeIsBetter="false"
>
<template #text="{ text }">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<LucideStar class="size-4 fill-ink-amber-2 text-ink-amber-2" />
<span class="text-2xl font-medium text-ink-gray-8">
{{ text }}
</span>
</div>
<span class="text-sm text-ink-gray-5">
{{ (chartData as any).total ?? 0 }} {{ __("reviews") }}
</span>
</div>
</template>
</BarChartCard>
<BarChartCard
v-if="key === 'sla_violations'"
:title="__('Failed SLAs')"
:data="chartData"
api-url="helpdesk.api.ticket_stats.get_sla_violations"
:dt="props.dt"
:dn="props.dn"
orientation="horizontal"
bar-color="#E03636"
/>
<LineChartCard
v-if="key === 'avg_first_response_time'"
:title="__('Avg. First Response Time')"
:data="chartData"
:chart-color="{
lineColor: '#E03636',
gradientColor: { start: '#ee9d9f', end: 'rgba(251,232,233,0)' },
}"
api-url="helpdesk.api.ticket_stats.get_avg_first_response_time"
:dt="props.dt"
:dn="props.dn"
orientation="horizontal"
/>
<LineChartCard
v-if="key === 'avg_resolution_time'"
:title="__('Avg. Resolution Time')"
:data="chartData"
:chart-color="{
lineColor: '#7263E8',
gradientColor: { start: '#a093ee', end: 'rgba(239, 237, 252,0)' },
}"
api-url="helpdesk.api.ticket_stats.get_avg_resolution_time"
:dt="props.dt"
:dn="props.dn"
orientation="horizontal"
/>
</div>
</div>
<SkeletonLoader
v-else-if="analytics.loading"
:variants="['number-cards']"
:loading="true"
orientation="horizontal"
:number-cards-count="4"
number-cards-grid-class="grid grid-cols-4 gap-2.5"
height="130px"
/>
</div>
</template>
<script setup lang="ts">
import { createResource } from "frappe-ui";
import BarChartCard from "../BarChartCard.vue";
import LineChartCard from "../LineChartCard.vue";
import SkeletonLoader from "../SkeletonLoader.vue";
const props = defineProps<{
dt: "HD Customer" | "Contact";
dn: string;
}>();
const analytics = createResource({
url: "helpdesk.api.ticket_stats.get_ticket_stats",
method: "GET",
makeParams: () => ({
dt: props.dt,
dn: props.dn,
}),
auto: true,
});
</script>
+327
View File
@@ -0,0 +1,327 @@
<template>
<div class="flex flex-col focus-visible:border-none" tabindex="0">
<!-- Filter bar -->
<div class="flex items-center justify-between gap-3 pb-3">
<FormControl
v-model="search"
:placeholder="__('Search by ID or Subject')"
class="w-full md:w-56"
>
<template #prefix>
<LucideSearch class="h-4 w-4 text-ink-gray-4" />
</template>
</FormControl>
<div class="flex items-center gap-2" v-if="!isMobileView">
<Link
class="w-48"
v-for="filter in filterFields"
:key="filter.key"
v-model="filters[filter.key]"
:placeholder="filter.placeholder"
:doctype="filter.doctype"
:filters="filter.filters"
/>
</div>
</div>
<!-- Table -->
<div class="min-h-0 flex flex-1 flex-col" tabindex="0">
<!-- Loading -->
<template v-if="ticketsListResource.loading">
<div
class="py-16 text-center text-sm text-ink-gray-4 flex items-center justify-center"
>
<LoadingIndicator :scale="10" />
</div>
</template>
<!-- Empty -->
<div
v-else-if="!Boolean(ticketsListResource.data?.length)"
class="flex flex-col items-center justify-center gap-3 py-16 text-center h-full flex-1"
>
<LucideTicket class="h-10 w-10 text-ink-gray-4" />
<div>
<!-- make font larger -->
<p class="text-lg font-medium text-ink-gray-7">
{{ __("No tickets found") }}
</p>
</div>
</div>
<!-- Main Content -->
<template v-else>
<!-- Headers -->
<div
class="grid items-center px-1 py-2 text-xs font-medium text-ink-gray-5"
:style="gridTemplateStyle"
>
<div
v-for="col in columns"
:key="col.key"
class="group flex items-center gap-1 select-none text-ink-gray-5"
:class="col.sortable ? 'hover:text-ink-gray-8' : ''"
@click="handleSortClick(col)"
>
{{ col.label }}
<LucideArrowUpDown
v-if="col.sortable"
class="h-3 w-3 transition-opacity"
:class="
sort.field === col.key
? 'opacity-100 text-ink-gray-7'
: 'opacity-0 group-hover:opacity-60'
"
/>
</div>
</div>
<!-- Rows -->
<div
class="min-h-0 overflow-y-auto pb-6"
:class="
isMobileView ? 'max-h-[65vh]' : 'max-h-[65vh] overflow-x-hidden'
"
>
<template
v-for="(ticket, i) in ticketsListResource.data"
:key="ticket.name"
>
<hr class="mx-1" v-if="i === 0" />
<div
class="grid items-center py-3 px-1 text-sm text-ink-gray-8 cursor-pointer hover:bg-surface-gray-1 rounded transition-colors"
:style="gridTemplateStyle"
@click="goToTicket(ticket.name)"
>
<!-- ID -->
<div class="text-ink-gray-6 font-base">{{ ticket.name }}</div>
<!-- Subject -->
<div class="truncate font-medium max-w-[90%]">
{{ ticket.subject }}
</div>
<!-- Status -->
<div class="flex items-center gap-1.5">
<IndicatorIcon
:class="getStatus(ticket.status)?.parsed_color"
/>
<span>{{ ticket.status }}</span>
</div>
<!-- Priority -->
<div v-if="!isMobileView" class="flex items-center gap-1.5">
<span>{{ ticket.priority }}</span>
</div>
<!-- First Response -->
<div v-if="!isMobileView" class="text-ink-gray-6">
{{ dayjsLocal(ticket.response_by).fromNow() }}
</div>
<!-- Resolution -->
<div v-if="!isMobileView" class="text-ink-gray-6">
{{ dayjsLocal(ticket.resolution_by).fromNow() }}
</div>
<!-- Assigned To -->
<div
v-if="!isMobileView"
class="flex items-center gap-2 assigned-cell"
>
<MultipleAvatar :avatars="ticket._assign" size="xs" />
</div>
</div>
<hr class="mx-1" v-if="i !== ticketRowsCount - 1" />
</template>
<!-- Load More -->
<div
class="flex justify-center py-6"
v-if="ticketsListResource.hasNextPage"
>
<Button
:loading="ticketsListResource.loading"
:label="__('Load More')"
icon-left="refresh-cw"
@click="
() => {
ticketsListResource.next();
}
"
/>
</div>
</div>
</template>
</div>
</div>
</template>
<script
setup
lang="ts"
generic="T extends Record<string, any> = Record<string, any>"
>
import Link from "@/components/frappe-ui/Link.vue";
import { IndicatorIcon } from "@/components/icons";
import { useScreenSize } from "@/composables/screen";
import { useTicketStatusStore } from "@/stores/ticketStatus";
import { __ } from "@/translation";
import type { DocumentResource, ListResource } from "@/types";
import type { HDTicket } from "@/types/doctypes";
import { watchDebounced } from "@vueuse/core";
import { dayjsLocal, FormControl, LoadingIndicator } from "frappe-ui";
import { computed, onBeforeUnmount, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import LucideSearch from "~icons/lucide/search";
import MultipleAvatar from "../MultipleAvatar.vue";
type TicketFilterField = {
key: string;
placeholder: string;
doctype: string;
filters?: Record<string, any>;
};
const props = defineProps<{
doc: DocumentResource<T>;
ticketsListResource: ListResource<HDTicket>;
baseFilter: Record<string, string>;
additionalFilter?: TicketFilterField;
}>();
const { doc, ticketsListResource, baseFilter } = props;
const router = useRouter();
const { isMobileView } = useScreenSize();
const search = ref("");
const sort = reactive<{ field: string | null; order: "asc" | "desc" }>({
field: null,
order: "asc",
});
const filters = reactive<Record<string, string>>({
status: "",
priority: "",
});
const gridTemplateStyle = computed(() =>
isMobileView.value
? "grid-template-columns: 5rem 1fr 7rem"
: "grid-template-columns: 6rem 1fr 8rem 7rem 8rem 8rem 9rem"
);
type Column = {
key: string;
label: string;
sortable: boolean;
desktopOnly?: boolean;
};
const allColumns: Column[] = [
{ key: "name", label: __("Ticket ID"), sortable: true },
{ key: "subject", label: __("Subject"), sortable: true },
{ key: "status", label: __("Status"), sortable: true },
{ key: "priority", label: __("Priority"), sortable: true, desktopOnly: true },
{
key: "response_by",
label: __("First Response"),
sortable: true,
desktopOnly: true,
},
{
key: "resolution_by",
label: __("Resolution"),
sortable: true,
desktopOnly: true,
},
{
key: "_assign",
label: __("Assigned To"),
sortable: false,
desktopOnly: true,
},
];
const columns = computed(() =>
isMobileView.value ? allColumns.filter((c) => !c.desktopOnly) : allColumns
);
const filterFields = computed(() => {
const base: TicketFilterField[] = [
{
key: "status",
placeholder: __("Status"),
doctype: "HD Ticket Status",
},
{
key: "priority",
placeholder: __("Priority"),
doctype: "HD Ticket Priority",
},
];
if (props.additionalFilter) {
base.push(props.additionalFilter);
}
return base;
});
function handleSortClick(column: Column) {
if (!column.sortable) return;
if (sort.field === column.key) {
sort.order = sort.order === "asc" ? "desc" : "asc";
} else {
sort.field = column.key;
sort.order = "desc";
}
}
const ticketRowsCount = computed(() => ticketsListResource.data?.length ?? 0);
const { getStatus } = useTicketStatusStore();
const goToTicket = (ticket: string) => {
const route = router.resolve({
name: "TicketAgent",
params: { ticketId: String(ticket) },
});
window.open(route.href, "_blank");
};
watchDebounced(
[search, () => sort.field, () => sort.order, () => ({ ...filters })],
() => {
ticketsListResource.update({
filters: {
...baseFilter,
status: filters.status || undefined,
priority: filters.priority || undefined,
...(props.additionalFilter
? {
[props.additionalFilter.key]:
filters[props.additionalFilter.key] || undefined,
}
: {}),
},
orderBy: sort.field ? `${sort.field} ${sort.order}` : undefined,
orFilters: search.value
? {
subject: ["like", `%${search.value}%`],
name: ["like", `%${search.value}%`],
}
: undefined,
});
ticketsListResource.reload();
},
{ debounce: 300 }
);
onBeforeUnmount(() => {
ticketsListResource.update({
filters: { ...baseFilter },
orFilters: {},
});
ticketsListResource.reload();
});
</script>
<style scoped>
.assigned-cell :deep(.truncate) {
@apply text-sm text-ink-gray-6;
}
</style>
@@ -1,263 +0,0 @@
<template>
<div>
<Dialog v-model:open="open" :title="__('Create New Contact')">
<template #default>
<div class="space-y-4">
<div
v-for="field in formFields"
:key="field.label"
class="flex flex-col gap-1"
>
<span class="mb-2 block text-sm leading-4 text-ink-gray-7">
{{ field.label }}
<span
v-if="field.required"
class="place-self-center text-ink-red-3"
>
*
</span>
</span>
<TextInput
v-if="field.type === 'input'"
v-model="state[field.value]"
type="text"
@blur="field.action"
/>
<Link
v-else-if="field.type === 'Link'"
:doctype="field.doctype"
v-model="state[field.value]"
:options="customerResource.data"
class="form-control flex-1"
:placeholder="__('Link to a customer record')"
:hide-me="true"
/>
<ErrorMessage :message="error[field.error]" />
</div>
<div class="flex justify-end gap-x-2">
<Button
:label="__('Create')"
:loading="contactResource.loading"
theme="gray"
variant="solid"
@click="createContact()"
/>
</div>
</div>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { useContactStore } from "@/stores/contact";
import { computed, ref } from "vue";
import {
Dialog,
ErrorMessage,
TextInput,
createListResource,
createResource,
toast,
} from "frappe-ui";
import zod from "zod";
import { __ } from "@/translation";
import { AutoCompleteItem } from "@/types";
interface Props {
modelValue: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
(event: "close"): void;
(event: "contactCreated"): void;
}>();
const contactStore = useContactStore();
const state = ref({
emailID: "",
firstName: "",
lastName: "",
phone: "",
selectedCustomer: null,
});
const error = ref({
emailValidationError: "",
firstNameValidationError: "",
lastNameValidationError: "",
phoneValidationError: "",
customerValidationError: "",
});
interface FormField {
label: string;
value: string;
error: string;
type: string;
required: boolean;
doctype?: string;
action?: () => void;
}
const formFields: FormField[] = [
{
label: __("Email Id"),
value: "emailID",
error: "emailValidationError",
type: "input",
required: true,
action: () => validateEmailInput(state.value.emailID),
},
{
label: __("First Name"),
value: "firstName",
error: "firstNameValidationError",
type: "input",
required: true,
action: () => validateFirstName(state.value.firstName),
},
{
label: __("Last Name"),
value: "lastName",
error: "lastNameValidationError",
type: "input",
required: false,
},
{
label: __("Phone"),
value: "phone",
error: "phoneValidationError",
type: "input",
required: false,
action: () => validatePhone(state.value.phone),
},
{
label: __("Customer"),
value: "selectedCustomer",
error: "customerValidationError",
type: "Link",
doctype: "HD Customer",
required: false,
},
];
const open = computed({
get: () => props.modelValue,
set: (val) => {
emit("update:modelValue", val);
if (!val) {
emit("close");
}
},
});
const customerResource = createListResource({
doctype: "HD Customer",
fields: ["name"],
cache: "customers",
transform: (data) => {
return data.map((option) => {
return {
label: option.name,
value: option.name,
};
});
},
auto: true,
});
const contactResource = createResource({
url: "frappe.client.insert",
onSuccess: () => {
state.value = {
emailID: "",
firstName: "",
lastName: "",
phone: "",
selectedCustomer: null,
};
toast.success(__("Contact created successfully."));
emit("contactCreated");
},
});
function createContact() {
if (validateInputs()) return;
let doc = {
doctype: "Contact",
first_name: state.value.firstName,
last_name: state.value.lastName,
email_ids: [{ email_id: state.value.emailID, is_primary: true }],
links: [],
phone_nos: [],
};
if (state.value.phone) {
doc.phone_nos = [{ phone: state.value.phone }];
}
if (state.value.selectedCustomer) {
doc.links.push({
link_doctype: "HD Customer",
link_name: state.value.selectedCustomer,
});
}
contactResource.submit({ doc });
}
function handleCustomerChange(item: AutoCompleteItem | null) {
if (!item || item.label === "No label") {
state.value.selectedCustomer = null;
} else {
state.value.selectedCustomer = item.value;
}
}
function validateInputs() {
let error = validateEmailInput(state.value.emailID);
error += validateFirstName(state.value.firstName);
error += validatePhone(state.value.phone);
return error;
}
function validateEmailInput(value: string) {
error.value.emailValidationError = "";
const success = zod.string().email().safeParse(value).success;
if (!value) {
error.value.emailValidationError = __("Email should not be empty");
} else if (!success) {
error.value.emailValidationError = __("Enter a valid email");
} else if (existingContactEmails(contactStore.options).includes(value)) {
error.value.emailValidationError = __("Contact with email already exists");
}
return error.value.emailValidationError;
}
function validateFirstName(value: string) {
error.value.firstNameValidationError = "";
if (!value || value.trim() === "") {
error.value.firstNameValidationError = __("First name should not be empty");
}
return error.value.firstNameValidationError;
}
function validatePhone(value: string) {
error.value.phoneValidationError = "";
const reg = /[0-9]+/;
if (value && (!reg.test(value) || value.length < 10)) {
error.value.phoneValidationError = __("Enter a valid phone number");
}
return error.value.phoneValidationError;
}
function existingContactEmails(contacts) {
return contacts.map((contact) => contact.email_id);
}
</script>
@@ -1,83 +0,0 @@
<template>
<div>
<Dialog v-model:open="model" title="Add New Customer" size="sm">
<template #default>
<div class="space-y-4">
<div class="space-y-1">
<TextInput
v-model="state.customer"
label="Customer Name"
type="text"
placeholder="Tesla Inc."
/>
</div>
<div class="space-y-1">
<TextInput
v-model="state.domain"
label="Domain"
type="text"
placeholder="eg: tesla.com, mycompany.com"
/>
</div>
<div class="float-end flex gap-x-2">
<Button
label="Add"
theme="gray"
variant="solid"
@click.prevent="addCustomer"
/>
</div>
</div>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import { Dialog, TextInput, createResource, toast } from "frappe-ui";
import { reactive } from "vue";
const emit = defineEmits(["customerCreated"]);
const model = defineModel<boolean>();
const state = reactive({
customer: "",
domain: "",
});
const customerResource = createResource({
url: "frappe.client.insert",
method: "POST",
data: {
doc: {
doctype: "HD Customer",
customer_name: state.customer,
domain: state.domain,
},
},
onSuccess: () => {
state.customer = "";
state.domain = "";
toast.success(__("Customer created successfully."));
emit("customerCreated");
},
onError: (err) => {
toast.error(err.messages[0]);
},
});
function addCustomer() {
if (!state.customer) {
toast.error("Customer name is required");
return;
}
customerResource.submit({
doc: {
doctype: "HD Customer",
customer_name: state.customer,
domain: state.domain,
},
});
}
</script>
@@ -87,7 +87,7 @@
>
<li
:class="[
'flex items-center rounded px-2.5 py-1.5 text-base',
'flex items-center rounded px-2.5 py-1.5 text-base cursor-pointer',
{ 'bg-surface-gray-2': active },
]"
>
+2 -2
View File
@@ -223,10 +223,10 @@ function clearValue(close) {
const labelClasses = computed(() => {
return [
{
sm: "text-xs",
sm: "text-p-sm",
md: "text-base",
}[attrs.size || "sm"],
"text-ink-gray-5",
"font-medium text-ink-gray-7",
...(attrs.required
? ["after:content-['*']", "after:ms-0.5", "after:text-ink-red-3"]
: []),
@@ -0,0 +1,367 @@
<template>
<div :class="['space-y-1.5', $attrs.class as string]">
<label
v-if="label"
:for="id"
class="block w-fit text-p-sm font-medium text-ink-gray-7"
>
{{ label }}
<span v-if="required" class="text-ink-red-3">*</span>
</label>
<Popover v-model:open="isOpen" matchTargetWidth>
<template #target="{ togglePopover }">
<div :class="containerClasses">
<button
type="button"
class="flex h-full items-center gap-1 rounded-l px-2 min-w-[50px] focus:outline-none"
:class="[
{ 'pointer-events-none': disabled },
flagCode ? '' : 'justify-center',
]"
:aria-label="__('Select country')"
@click.stop="togglePopover()"
>
<img
v-if="flagCode"
:src="`https://flagcdn.com/${flagCode}.svg`"
:alt="selectedCountry ?? ''"
class="h-3 w-4 rounded-sm object-cover"
/>
<FeatherIcon name="chevron-down" class="size-3.5 text-ink-gray-5" />
</button>
<div
class="self-stretch border-l border-outline-gray-2"
aria-hidden="true"
/>
<span
v-if="isd"
class="select-none ps-2.5 text-base"
:class="textColorClass"
>
{{ isd }}
</span>
<TextInput
:id="id"
ref="numberInputRef"
v-model="localNumber"
type="tel"
variant="ghost"
:size="size"
:placeholder="placeholder"
:disabled="disabled"
:autofocus="autofocus"
inputmode="tel"
autocomplete="off"
class="min-w-0 flex-1 [&_input]:!bg-transparent [&_input]:!ps-1"
@keydown.backspace="onBackspace"
/>
<div
v-if="$slots.suffix"
class="flex items-center gap-2 ps-3.5 pe-2.5"
>
<slot name="suffix" />
</div>
</div>
</template>
<template #body="{ close }">
<div
class="mt-1 flex max-h-72 flex-col overflow-hidden rounded-lg border border-outline-gray-2 bg-surface-modal shadow-lg"
>
<div class="border-b border-outline-gray-1 p-2">
<FormControl
v-model="searchQuery"
size="sm"
autocomplete="one-time-code"
:name="`country-search-${id}`"
:placeholder="__('Search country')"
:autofocus="true"
@keydown.down.prevent="moveHighlight(1)"
@keydown.up.prevent="moveHighlight(-1)"
@keydown.enter.prevent="commitHighlighted(close)"
@keydown.escape.prevent="close()"
/>
</div>
<div class="flex-1 overflow-y-auto p-1">
<button
v-for="(country, idx) in filteredCountries"
:key="country.name"
:ref="(el) => setItemRef(el as HTMLElement | null, idx)"
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-base text-ink-gray-7 outline-none"
:class="
idx === highlightedIndex
? 'bg-surface-gray-3'
: 'hover:bg-surface-gray-2'
"
@mouseenter="highlightedIndex = idx"
@click="onSelectCountry(country.name, close)"
>
<img
:src="`https://flagcdn.com/${country.code}.svg`"
:alt="country.name"
class="h-3 w-4 rounded-sm object-cover"
/>
<span class="flex-1 truncate">
{{ country.name }}
</span>
<span class="text-ink-gray-5">{{ country.isd }}</span>
</button>
<div
v-if="filteredCountries.length === 0"
class="p-3 text-center text-base text-ink-gray-5"
>
{{ __("No country found") }}
</div>
</div>
</div>
</template>
</Popover>
<p v-if="description" :class="descriptionClasses">
{{ description }}
</p>
</div>
</template>
<script setup lang="ts">
// TODO: replace with reka-ui in future
import { __ } from "@/translation";
import { FeatherIcon, FormControl, Popover, TextInput } from "frappe-ui";
import { computed, ref, useId, watch } from "vue";
import countries from "./countries.json";
interface CountryInfo {
code: string;
isd: string;
}
const props = withDefaults(
defineProps<{
label?: string;
placeholder?: string;
description?: string;
disabled?: boolean;
required?: boolean;
autofocus?: boolean;
defaultCountry?: string;
size?: "sm" | "md";
variant?: "subtle" | "outline";
}>(),
{
placeholder: "",
disabled: false,
required: false,
autofocus: false,
size: "sm",
variant: "subtle",
}
);
defineOptions({ inheritAttrs: false });
const model = defineModel<string>({ default: "" });
const id = useId();
const countryCodes: Record<string, CountryInfo> = countries;
const selectedCountry = ref<string | null>(null);
const isd = computed(
() => countryCodes[selectedCountry.value ?? ""]?.isd ?? ""
);
const flagCode = computed(
() => countryCodes[selectedCountry.value ?? ""]?.code ?? ""
);
const localNumber = ref<string>("");
const isOpen = ref(false);
const searchQuery = ref("");
const numberInputRef = ref<{ el?: HTMLInputElement | null } | null>(null);
const highlightedIndex = ref(0);
const itemRefs: HTMLElement[] = [];
function setItemRef(el: HTMLElement | null, idx: number) {
if (el) itemRefs[idx] = el;
}
function moveHighlight(delta: number) {
const total = filteredCountries.value.length;
if (total === 0) return;
highlightedIndex.value = (highlightedIndex.value + delta + total) % total;
itemRefs[highlightedIndex.value]?.scrollIntoView({ block: "nearest" });
}
function commitHighlighted(close: () => void) {
const country = filteredCountries.value[highlightedIndex.value];
if (country) onSelectCountry(country.name, close);
}
const allCountries = Object.entries(countryCodes)
.map(([name, info]) => ({ name, code: info.code, isd: info.isd }))
.sort((a, b) => a.name.localeCompare(b.name));
const filteredCountries = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return allCountries;
return allCountries.filter(
(c) => c.name.toLowerCase().includes(query) || c.isd.includes(query)
);
});
const sizeClasses = computed(
() =>
({
sm: "h-7 rounded",
md: "h-8 rounded",
}[props.size])
);
const variantClasses = computed(() => {
if (props.disabled) {
return [
"border bg-surface-gray-1",
props.variant === "outline"
? "border-outline-gray-2"
: "border-transparent",
];
}
return {
subtle:
"border border-[--surface-gray-2] bg-surface-gray-2 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:hover:bg-surface-white focus-within:hover:border-outline-gray-4 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-outline-gray-3",
outline:
"border border-outline-gray-2 bg-surface-white hover:border-outline-gray-3 hover:shadow-sm focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:hover:border-outline-gray-4 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-outline-gray-3",
}[props.variant];
});
const containerClasses = computed(() => [
"flex items-center w-full transition-colors",
sizeClasses.value,
variantClasses.value,
]);
const textColorClass = computed(() =>
props.disabled ? "text-ink-gray-5" : "text-ink-gray-8"
);
const descriptionClasses = computed(() => [
props.size === "md" ? "text-p-base" : "text-p-xs",
"text-ink-gray-5",
]);
function parseValue(value: unknown) {
const phoneNumber =
typeof value === "string"
? value
: typeof value === "number"
? String(value)
: "";
if (!phoneNumber) {
localNumber.value = "";
return;
}
// Form 1: "+ISD-NUMBER" (canonical, with dash separator)
const dashIdx = phoneNumber.indexOf("-");
if (dashIdx > 0) {
const isdPart = phoneNumber.substring(0, dashIdx);
const numberPart = phoneNumber.substring(dashIdx + 1);
const match = Object.entries(countryCodes).find(
([, info]) => info.isd === isdPart
);
if (match) {
selectedCountry.value = match[0];
localNumber.value = numberPart;
return;
}
}
// Form 2: "+ISDNUMBER"
// Pick the longest matching ISD prefix so +1 → US doesn't shadow +1242 → Bahamas.
if (phoneNumber.startsWith("+")) {
let bestName: string | null = null;
let bestIsd = "";
for (const [countryName, info] of Object.entries(countryCodes)) {
if (
info.isd &&
phoneNumber.startsWith(info.isd) &&
info.isd.length > bestIsd.length
) {
bestName = countryName;
bestIsd = info.isd;
}
}
if (bestName) {
selectedCountry.value = bestName;
localNumber.value = phoneNumber.substring(bestIsd.length);
return;
}
}
localNumber.value = phoneNumber;
}
function getDefaultCountry(): string | null {
const country = window.default_country;
return typeof country === "string" && country ? country : null;
}
function applyDefaultCountry() {
if (selectedCountry.value) return;
const candidates = [props.defaultCountry, getDefaultCountry(), "India"];
for (const name of candidates) {
if (name) {
selectedCountry.value = name;
return;
}
}
}
function onSelectCountry(name: string, close: () => void) {
selectedCountry.value = name;
searchQuery.value = "";
close();
numberInputRef.value?.el?.focus();
}
function onBackspace() {
if (!localNumber.value) selectedCountry.value = null;
}
function emitValue() {
const number = localNumber.value;
if (!number) {
model.value = "";
return;
}
model.value = isd.value ? `${isd.value}-${number}` : number;
}
parseValue(model.value);
if (!model.value) applyDefaultCountry();
watch([localNumber, isd], emitValue);
watch(
() => model.value,
(next) => {
const current = isd.value
? `${isd.value}-${localNumber.value}`
: localNumber.value;
if (next === current) return;
parseValue(next);
}
);
watch(isOpen, (open) => {
if (open) {
highlightedIndex.value = 0;
} else {
searchQuery.value = "";
}
});
watch(searchQuery, () => {
highlightedIndex.value = 0;
});
</script>
@@ -0,0 +1,982 @@
{
"Afghanistan": {
"code": "af",
"isd": "+93"
},
"Albania": {
"code": "al",
"isd": "+355"
},
"Algeria": {
"code": "dz",
"isd": "+213"
},
"American Samoa": {
"code": "as",
"isd": "+1684"
},
"Andorra": {
"code": "ad",
"isd": "+376"
},
"Angola": {
"code": "ao",
"isd": "+244"
},
"Anguilla": {
"code": "ai",
"isd": "+1264"
},
"Antarctica": {
"code": "aq",
"isd": "+672"
},
"Antigua and Barbuda": {
"code": "ag",
"isd": "+1268"
},
"Argentina": {
"code": "ar",
"isd": "+54"
},
"Armenia": {
"code": "am",
"isd": "+374"
},
"Aruba": {
"code": "aw",
"isd": "+297"
},
"Australia": {
"code": "au",
"isd": "+61"
},
"Austria": {
"code": "at",
"isd": "+43"
},
"Azerbaijan": {
"code": "az",
"isd": "+994"
},
"Bahamas": {
"code": "bs",
"isd": "+1242"
},
"Bahrain": {
"code": "bh",
"isd": "+973"
},
"Bangladesh": {
"code": "bd",
"isd": "+880"
},
"Barbados": {
"code": "bb",
"isd": "+1246"
},
"Belarus": {
"code": "by",
"isd": "+375"
},
"Belgium": {
"code": "be",
"isd": "+32"
},
"Belize": {
"code": "bz",
"isd": "+501"
},
"Benin": {
"code": "bj",
"isd": "+229"
},
"Bermuda": {
"code": "bm",
"isd": "+1441"
},
"Bhutan": {
"code": "bt",
"isd": "+975"
},
"Bolivia, Plurinational State of": {
"code": "bo",
"isd": "+591"
},
"Bosnia and Herzegovina": {
"code": "ba",
"isd": "+387"
},
"Botswana": {
"code": "bw",
"isd": "+267"
},
"Bouvet Island": {
"code": "bv",
"isd": "+47"
},
"Brazil": {
"code": "br",
"isd": "+55"
},
"British Indian Ocean Territory": {
"code": "io",
"isd": "+246"
},
"Brunei Darussalam": {
"code": "bn",
"isd": "+673"
},
"Bulgaria": {
"code": "bg",
"isd": "+359"
},
"Burkina Faso": {
"code": "bf",
"isd": "+226"
},
"Burundi": {
"code": "bi",
"isd": "+257"
},
"Cambodia": {
"code": "kh",
"isd": "+855"
},
"Cameroon": {
"code": "cm",
"isd": "+237"
},
"Canada": {
"code": "ca",
"isd": "+1"
},
"Cape Verde": {
"code": "cv",
"isd": "+238"
},
"Cayman Islands": {
"code": "ky",
"isd": "+ 345"
},
"Central African Republic": {
"code": "cf",
"isd": "+236"
},
"Chad": {
"code": "td",
"isd": "+235"
},
"Chile": {
"code": "cl",
"isd": "+56"
},
"China": {
"code": "cn",
"isd": "+86"
},
"Christmas Island": {
"code": "cx",
"isd": "+61"
},
"Cocos (Keeling) Islands": {
"code": "cc",
"isd": "+61"
},
"Colombia": {
"code": "co",
"isd": "+57"
},
"Comoros": {
"code": "km",
"isd": "+269"
},
"Congo": {
"code": "cg",
"isd": "+242"
},
"Congo, The Democratic Republic of the": {
"code": "cd",
"isd": "+243"
},
"Cook Islands": {
"code": "ck",
"isd": "+682"
},
"Costa Rica": {
"code": "cr",
"isd": "+506"
},
"Croatia": {
"code": "hr",
"isd": "+385"
},
"Cuba": {
"code": "cu",
"isd": "+53"
},
"Cyprus": {
"code": "cy",
"isd": "+357"
},
"Czech Republic": {
"code": "cz",
"isd": "+420"
},
"Denmark": {
"code": "dk",
"isd": "+45"
},
"Djibouti": {
"code": "dj",
"isd": "+253"
},
"Dominica": {
"code": "dm",
"isd": "+1767"
},
"Dominican Republic": {
"code": "do",
"isd": "+1849"
},
"Ecuador": {
"code": "ec",
"isd": "+593"
},
"Egypt": {
"code": "eg",
"isd": "+20"
},
"El Salvador": {
"code": "sv",
"isd": "+503"
},
"Equatorial Guinea": {
"code": "gq",
"isd": "+240"
},
"Eritrea": {
"code": "er",
"isd": "+291"
},
"Estonia": {
"code": "ee",
"isd": "+372"
},
"Ethiopia": {
"code": "et",
"isd": "+251"
},
"Falkland Islands (Malvinas)": {
"code": "fk",
"isd": "+500"
},
"Faroe Islands": {
"code": "fo",
"isd": "+298"
},
"Fiji": {
"code": "fj",
"isd": "+679"
},
"Finland": {
"code": "fi",
"isd": "+358"
},
"France": {
"code": "fr",
"isd": "+33"
},
"French Guiana": {
"code": "gf",
"isd": "+594"
},
"French Polynesia": {
"code": "pf",
"isd": "+689"
},
"French Southern Territories": {
"code": "tf",
"isd": "+262"
},
"Gabon": {
"code": "ga",
"isd": "+241"
},
"Gambia": {
"code": "gm",
"isd": "+220"
},
"Georgia": {
"code": "ge",
"isd": "+995"
},
"Germany": {
"code": "de",
"isd": "+49"
},
"Ghana": {
"code": "gh",
"isd": "+233"
},
"Gibraltar": {
"code": "gi",
"isd": "+350"
},
"Greece": {
"code": "gr",
"isd": "+30"
},
"Greenland": {
"code": "gl",
"isd": "+299"
},
"Grenada": {
"code": "gd",
"isd": "+1473"
},
"Guadeloupe": {
"code": "gp",
"isd": "+590"
},
"Guam": {
"code": "gu",
"isd": "+1671"
},
"Guatemala": {
"code": "gt",
"isd": "+502"
},
"Guernsey": {
"code": "gg",
"isd": "+44"
},
"Guinea": {
"code": "gn",
"isd": "+224"
},
"Guinea-Bissau": {
"code": "gw",
"isd": "+245"
},
"Guyana": {
"code": "gy",
"isd": "+592"
},
"Haiti": {
"code": "ht",
"isd": "+509"
},
"Heard Island and McDonald Islands": {
"code": "hm",
"isd": "+0"
},
"Holy See (Vatican City State)": {
"code": "va",
"isd": "+379"
},
"Honduras": {
"code": "hn",
"isd": "+504"
},
"Hong Kong": {
"code": "hk",
"isd": "+852"
},
"Hungary": {
"code": "hu",
"isd": "+36"
},
"Iceland": {
"code": "is",
"isd": "+354"
},
"India": {
"code": "in",
"isd": "+91"
},
"Indonesia": {
"code": "id",
"isd": "+62"
},
"Iran": {
"code": "ir",
"isd": "+98"
},
"Iraq": {
"code": "iq",
"isd": "+964"
},
"Ireland": {
"code": "ie",
"isd": "+353"
},
"Isle of Man": {
"code": "im",
"isd": "+44"
},
"Israel": {
"code": "il",
"isd": "+972"
},
"Italy": {
"code": "it",
"isd": "+39"
},
"Ivory Coast": {
"code": "ci",
"isd": "+225"
},
"Jamaica": {
"code": "jm",
"isd": "+1876"
},
"Japan": {
"code": "jp",
"isd": "+81"
},
"Jersey": {
"code": "je",
"isd": "+44"
},
"Jordan": {
"code": "jo",
"isd": "+962"
},
"Kazakhstan": {
"code": "kz",
"isd": "+7"
},
"Kenya": {
"code": "ke",
"isd": "+254"
},
"Kiribati": {
"code": "ki",
"isd": "+686"
},
"Korea, Democratic Peoples Republic of": {
"code": "kp",
"isd": "+850"
},
"Korea, Republic of": {
"code": "kr",
"isd": "+82"
},
"Kosovo": {
"code": "xk",
"isd": "+383"
},
"Kuwait": {
"code": "kw",
"isd": "+965"
},
"Kyrgyzstan": {
"code": "kg",
"isd": "+996"
},
"Lao Peoples Democratic Republic": {
"code": "la",
"isd": "+856"
},
"Latvia": {
"code": "lv",
"isd": "+371"
},
"Lebanon": {
"code": "lb",
"isd": "+961"
},
"Lesotho": {
"code": "ls",
"isd": "+266"
},
"Liberia": {
"code": "lr",
"isd": "+231"
},
"Libya": {
"code": "ly",
"isd": "+218"
},
"Liechtenstein": {
"code": "li",
"isd": "+423"
},
"Lithuania": {
"code": "lt",
"isd": "+370"
},
"Luxembourg": {
"code": "lu",
"isd": "+352"
},
"Macao": {
"code": "mo",
"isd": "+853"
},
"Macedonia": {
"code": "mk",
"isd": "+389"
},
"Madagascar": {
"code": "mg",
"isd": "+261"
},
"Malawi": {
"code": "mw",
"isd": "+265"
},
"Malaysia": {
"code": "my",
"isd": "+60"
},
"Maldives": {
"code": "mv",
"isd": "+960"
},
"Mali": {
"code": "ml",
"isd": "+223"
},
"Malta": {
"code": "mt",
"isd": "+356"
},
"Marshall Islands": {
"code": "mh",
"isd": "+692"
},
"Martinique": {
"code": "mq",
"isd": "+596"
},
"Mauritania": {
"code": "mr",
"isd": "+222"
},
"Mauritius": {
"code": "mu",
"isd": "+230"
},
"Mayotte": {
"code": "yt",
"isd": "+262"
},
"Mexico": {
"code": "mx",
"isd": "+52"
},
"Micronesia, Federated States of": {
"code": "fm",
"isd": "+691"
},
"Moldova, Republic of": {
"code": "md",
"isd": "+373"
},
"Monaco": {
"code": "mc",
"isd": "+377"
},
"Mongolia": {
"code": "mn",
"isd": "+976"
},
"Montenegro": {
"code": "me",
"isd": "+382"
},
"Montserrat": {
"code": "ms",
"isd": "+1664"
},
"Morocco": {
"code": "ma",
"isd": "+212"
},
"Mozambique": {
"code": "mz",
"isd": "+258"
},
"Myanmar": {
"code": "mm",
"isd": "+95"
},
"Namibia": {
"code": "na",
"isd": "+264"
},
"Nauru": {
"code": "nr",
"isd": "+674"
},
"Nepal": {
"code": "np",
"isd": "+977"
},
"Netherlands": {
"code": "nl",
"isd": "+31"
},
"New Caledonia": {
"code": "nc",
"isd": "+687"
},
"New Zealand": {
"code": "nz",
"isd": "+64"
},
"Nicaragua": {
"code": "ni",
"isd": "+505"
},
"Niger": {
"code": "ne",
"isd": "+227"
},
"Nigeria": {
"code": "ng",
"isd": "+234"
},
"Niue": {
"code": "nu",
"isd": "+683"
},
"Norfolk Island": {
"code": "nf",
"isd": "+672"
},
"Northern Mariana Islands": {
"code": "mp",
"isd": "+1670"
},
"Norway": {
"code": "no",
"isd": "+47"
},
"Oman": {
"code": "om",
"isd": "+968"
},
"Pakistan": {
"code": "pk",
"isd": "+92"
},
"Palau": {
"code": "pw",
"isd": "+680"
},
"Palestinian Territory, Occupied": {
"code": "ps",
"isd": "+970"
},
"Panama": {
"code": "pa",
"isd": "+507"
},
"Papua New Guinea": {
"code": "pg",
"isd": "+675"
},
"Paraguay": {
"code": "py",
"isd": "+595"
},
"Peru": {
"code": "pe",
"isd": "+51"
},
"Philippines": {
"code": "ph",
"isd": "+63"
},
"Pitcairn": {
"code": "pn",
"isd": "+64"
},
"Poland": {
"code": "pl",
"isd": "+48"
},
"Portugal": {
"code": "pt",
"isd": "+351"
},
"Puerto Rico": {
"code": "pr",
"isd": "+1939"
},
"Qatar": {
"code": "qa",
"isd": "+974"
},
"Romania": {
"code": "ro",
"isd": "+40"
},
"Russian Federation": {
"code": "ru",
"isd": "+7"
},
"Rwanda": {
"code": "rw",
"isd": "+250"
},
"Réunion": {
"code": "re",
"isd": "+262"
},
"Saint Barthélemy": {
"code": "bl",
"isd": "+590"
},
"Saint Helena, Ascension and Tristan da Cunha": {
"code": "sh",
"isd": "+290"
},
"Saint Kitts and Nevis": {
"code": "kn",
"isd": "+1869"
},
"Saint Lucia": {
"code": "lc",
"isd": "+1758"
},
"Saint Martin (French part)": {
"code": "mf",
"isd": "+590"
},
"Saint Pierre and Miquelon": {
"code": "pm",
"isd": "+508"
},
"Saint Vincent and the Grenadines": {
"code": "vc",
"isd": "+1784"
},
"Samoa": {
"code": "ws",
"isd": "+685"
},
"San Marino": {
"code": "sm",
"isd": "+378"
},
"Sao Tome and Principe": {
"code": "st",
"isd": "+239"
},
"Saudi Arabia": {
"code": "sa",
"isd": "+966"
},
"Senegal": {
"code": "sn",
"isd": "+221"
},
"Serbia": {
"code": "rs",
"isd": "+381"
},
"Seychelles": {
"code": "sc",
"isd": "+248"
},
"Sierra Leone": {
"code": "sl",
"isd": "+232"
},
"Singapore": {
"code": "sg",
"isd": "+65"
},
"Slovakia": {
"code": "sk",
"isd": "+421"
},
"Slovenia": {
"code": "si",
"isd": "+386"
},
"Solomon Islands": {
"code": "sb",
"isd": "+677"
},
"Somalia": {
"code": "so",
"isd": "+252"
},
"South Africa": {
"code": "za",
"isd": "+27"
},
"South Georgia and the South Sandwich Islands": {
"code": "gs",
"isd": "+500"
},
"South Sudan": {
"code": "ss",
"isd": "+211"
},
"Spain": {
"code": "es",
"isd": "+34"
},
"Sri Lanka": {
"code": "lk",
"isd": "+94"
},
"Sudan": {
"code": "sd",
"isd": "+249"
},
"Suriname": {
"code": "sr",
"isd": "+597"
},
"Svalbard and Jan Mayen": {
"code": "sj",
"isd": "+47"
},
"Swaziland": {
"code": "sz",
"isd": "+268"
},
"Sweden": {
"code": "se",
"isd": "+46"
},
"Switzerland": {
"code": "ch",
"isd": "+41"
},
"Syria": {
"code": "sy",
"isd": "+963"
},
"Taiwan": {
"code": "tw",
"isd": "+886"
},
"Tajikistan": {
"code": "tj",
"isd": "+992"
},
"Tanzania": {
"code": "tz",
"isd": "+255"
},
"Thailand": {
"code": "th",
"isd": "+66"
},
"Timor-Leste": {
"code": "tl",
"isd": "+670"
},
"Togo": {
"code": "tg",
"isd": "+228"
},
"Tokelau": {
"code": "tk",
"isd": "+690"
},
"Tonga": {
"code": "to",
"isd": "+676"
},
"Trinidad and Tobago": {
"code": "tt",
"isd": "+1868"
},
"Tunisia": {
"code": "tn",
"isd": "+216"
},
"Turkmenistan": {
"code": "tm",
"isd": "+993"
},
"Turks and Caicos Islands": {
"code": "tc",
"isd": "+1649"
},
"Tuvalu": {
"code": "tv",
"isd": "+688"
},
"Türkiye": {
"code": "tr",
"isd": "+90"
},
"Uganda": {
"code": "ug",
"isd": "+256"
},
"Ukraine": {
"code": "ua",
"isd": "+380"
},
"United Arab Emirates": {
"code": "ae",
"isd": "+971"
},
"United Kingdom": {
"code": "gb",
"isd": "+44"
},
"United States": {
"code": "us",
"isd": "+1"
},
"Uruguay": {
"code": "uy",
"isd": "+598"
},
"Uzbekistan": {
"code": "uz",
"isd": "+998"
},
"Vanuatu": {
"code": "vu",
"isd": "+678"
},
"Venezuela, Bolivarian Republic of": {
"code": "ve",
"isd": "+58"
},
"Vietnam": {
"code": "vn",
"isd": "+84"
},
"Virgin Islands, British": {
"code": "vg",
"isd": "+1284"
},
"Virgin Islands, U.S.": {
"code": "vi",
"isd": "+1340"
},
"Wallis and Futuna": {
"code": "wf",
"isd": "+681"
},
"Yemen": {
"code": "ye",
"isd": "+967"
},
"Zambia": {
"code": "zm",
"isd": "+260"
},
"Zimbabwe": {
"code": "zw",
"isd": "+263"
},
"Åland Islands": {
"code": "ax",
"isd": "+358"
}
}
@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.98766 1.30058C7.55086 0.828933 9.22972 0.917962 10.7338 1.55351C11.9679 2.07506 13.0206 2.93743 13.7738 4.03105L13.9945 2.44609C14.0327 2.17294 14.2847 1.9825 14.558 2.02031C14.8314 2.05828 15.0225 2.3104 14.9847 2.58379L14.5931 5.40508C14.5551 5.67859 14.3022 5.86982 14.0287 5.83183L11.2074 5.44023C10.934 5.40212 10.7427 5.14921 10.7806 4.87578C10.8187 4.60248 11.0717 4.41219 11.3451 4.45L13.0033 4.67949C12.3546 3.70429 11.4327 2.93514 10.3451 2.47539C9.05581 1.93053 7.61574 1.85325 6.27574 2.25762C4.93592 2.66203 3.77978 3.52229 3.00719 4.68926C2.23453 5.85633 1.89425 7.25675 2.04527 8.64824C2.19631 10.0397 2.82931 11.3343 3.83434 12.3084C4.83941 13.2825 6.15364 13.8748 7.54918 13.9822C8.94459 14.0896 10.3337 13.706 11.4759 12.8973C12.6183 12.0885 13.4418 10.9055 13.8041 9.55351C13.8756 9.28687 14.1497 9.12858 14.4164 9.2C14.6831 9.27146 14.8413 9.5456 14.7699 9.8123C14.3472 11.3896 13.3868 12.7701 12.0541 13.7137C10.7213 14.6573 9.10019 15.1046 7.47203 14.9793C5.84391 14.8539 4.31065 14.1636 3.13805 13.0271C1.96555 11.8908 1.22743 10.3799 1.05113 8.75664C0.874935 7.13329 1.27186 5.49907 2.1732 4.1375C3.07467 2.77588 4.4243 1.77235 5.98766 1.30058ZM8.00914 3.77715C8.28512 3.77715 8.50887 4.00123 8.50914 4.27715L8.51012 8.26933L10.4398 10.16C10.6367 10.3532 10.6396 10.6698 10.4466 10.867C10.2534 11.0639 9.93674 11.0669 9.73961 10.8738L7.66051 8.83672L7.51012 8.68926L7.50914 8.47832V4.27715C7.50941 4.00148 7.7335 3.77754 8.00914 3.77715Z"
fill="currentColor"
/>
</svg>
</template>
@@ -0,0 +1,14 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.99805 1.5C11.5864 1.5002 14.4951 4.40965 14.4951 7.99805C14.4949 11.5863 11.5863 14.4949 7.99805 14.4951C7.69102 14.4951 7.38795 14.4735 7.0918 14.4316L6.79785 14.3828C6.04932 14.2412 5.44817 13.9869 5.01465 13.7617C4.67831 13.9399 4.25056 14.1115 3.80176 14.2422C3.2253 14.4101 2.56613 14.5274 1.97266 14.4951C1.77577 14.4843 1.60374 14.358 1.5332 14.1738C1.46282 13.9897 1.50717 13.7811 1.64648 13.6416C1.9468 13.3408 2.20258 12.8447 2.33789 12.3301C2.40435 12.0772 2.43706 11.837 2.43652 11.6348C2.43595 11.4262 2.40028 11.2934 2.36426 11.2256H2.36328C1.81788 10.2756 1.50007 9.17494 1.5 7.99805C1.5 4.40953 4.40953 1.50001 7.99805 1.5ZM7.99805 2.5C4.96182 2.50001 2.5 4.96182 2.5 7.99805C2.50006 8.87225 2.70766 9.69658 3.07227 10.4307L3.23828 10.7402L3.24609 10.7549C3.38774 11.0205 3.43573 11.3351 3.43652 11.6328C3.43728 11.9374 3.38874 12.2642 3.30469 12.584C3.23282 12.8574 3.12876 13.1377 2.99902 13.4062C3.1728 13.3717 3.34941 13.3317 3.52246 13.2812C4.03861 13.1309 4.4832 12.9329 4.73438 12.7705L4.79492 12.7373C4.94106 12.6694 5.11275 12.6751 5.25488 12.7568C5.62683 12.9713 6.21481 13.2549 6.9834 13.4004C7.31107 13.4624 7.65135 13.4951 7.99805 13.4951C11.034 13.4949 13.4949 11.034 13.4951 7.99805C13.4951 4.96193 11.0341 2.5002 7.99805 2.5ZM5.5 7C5.91421 7 6.25 7.33579 6.25 7.75C6.25 8.16421 5.91421 8.5 5.5 8.5C5.08579 8.5 4.75 8.16421 4.75 7.75C4.75 7.33579 5.08579 7 5.5 7ZM8 7C8.41421 7 8.75 7.33579 8.75 7.75C8.75 8.16421 8.41421 8.5 8 8.5C7.58579 8.5 7.25 8.16421 7.25 7.75C7.25 7.33579 7.58579 7 8 7ZM10.5 7C10.9142 7 11.25 7.33579 11.25 7.75C11.25 8.16421 10.9142 8.5 10.5 8.5C10.0858 8.5 9.75 8.16421 9.75 7.75C9.75 7.33579 10.0858 7 10.5 7Z"
fill="currentColor"
/>
</svg>
</template>
@@ -0,0 +1,14 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.3818 1.50195C11.6565 1.52785 11.8586 1.77211 11.833 2.04688L11.5732 4.83398H14.0029C14.279 4.83399 14.5029 5.05789 14.5029 5.33398C14.5028 5.61004 14.279 5.83397 14.0029 5.83398H6.0791L5.59766 10.1689H9.92578L10.1729 7.94531C10.2037 7.67136 10.4505 7.47363 10.7246 7.50391C10.999 7.53439 11.1963 7.7823 11.166 8.05664L10.9316 10.1689H14.0029C14.279 10.169 14.5028 10.3929 14.5029 10.6689C14.5028 10.945 14.279 11.1689 14.0029 11.1689H10.8203L10.5 14.0576C10.4695 14.3321 10.2217 14.5295 9.94727 14.499C9.67305 14.4683 9.4754 14.2216 9.50586 13.9473L9.81445 11.1689H5.48633L5.16504 14.0586C5.13433 14.3327 4.88653 14.5304 4.6123 14.5C4.33807 14.4693 4.14042 14.2216 4.1709 13.9473L4.47949 11.1689H2C1.72391 11.1689 1.50009 10.945 1.5 10.6689C1.50008 10.3929 1.72391 10.1689 2 10.1689H4.59082L5.07227 5.83398H2C1.72391 5.83398 1.50009 5.61005 1.5 5.33398C1.50004 5.05788 1.72388 4.83398 2 4.83398H5.18359L5.50488 1.94434C5.53563 1.67023 5.78245 1.47258 6.05664 1.50293C6.33097 1.53352 6.52947 1.7813 6.49902 2.05566L6.19043 4.83398H10.5684L10.8379 1.95312C10.8638 1.67851 11.1072 1.47644 11.3818 1.50195Z"
fill="currentColor"
/>
</svg>
</template>
-1
View File
@@ -15,7 +15,6 @@ export { default as Icon } from "./Icon.vue";
export { default as LayoutHeader } from "./LayoutHeader.vue";
export { default as ListViewBuilder } from "./ListViewBuilder.vue";
export { default as MultipleAvatar } from "./MultipleAvatar.vue";
export { default as MultiSelectInput } from "./MultiSelectInput.vue";
export { default as NestedPopover } from "./NestedPopover.vue";
export { default as Notifications } from "./notifications/Notifications.vue";
export { default as PageTitle } from "./PageTitle.vue";
+1 -1
View File
@@ -184,7 +184,7 @@ import { useDevice } from "@/composables";
import { confirmLoginToFrappeCloud } from "@/composables/fc";
import { useScreenSize } from "@/composables/screen";
import { currentView, useView } from "@/composables/useView";
import { showNewContactModal } from "@/pages/desk/contact/dialogState";
import { showNewContactModal } from "@/pages/contact/dialogState";
import {
showAssignmentModal,
showCommentBox,
@@ -52,7 +52,6 @@ import { ExternalLinkIcon } from "../icons";
import EmailIcon from "../icons/EmailIcon.vue";
import PhoneIcon from "../icons/PhoneIcon.vue";
import SetContactPhoneModal from "../ticket/SetContactPhoneModal.vue";
const telephonyStore = useTelephonyStore();
const { getUser } = useUserStore();
const { isCallingEnabled } = storeToRefs(telephonyStore);
+644
View File
@@ -0,0 +1,644 @@
import { __ } from "@/translation";
import type {
DocumentResource,
EditContactState,
Error,
ListResource,
NewContactState,
Resource,
} from "@/types";
import { Contact, HDTicket } from "@/types/doctypes";
import { getErrorMessage, validateEmailWithZod } from "@/utils";
import {
call,
createDocumentResource,
createListResource,
createResource,
toast,
} from "frappe-ui";
import { useOnboarding } from "frappe-ui/frappe";
import { computed, ComputedRef, h, markRaw, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
export type TextInputTypes =
| "date"
| "datetime-local"
| "email"
| "file"
| "month"
| "number"
| "password"
| "search"
| "tel"
| "text"
| "time"
| "url"
| "week"
| "range";
type FieldType =
| TextInputTypes
| "file"
| "Link"
| "autocomplete"
| "email_ids"
| "phone_nos"
| "timezone";
type AutocompleteOption = string;
type ContactInfoResource = ReturnType<typeof createResource>;
interface ContactBundle {
doc: DocumentResource<Contact>;
info: ContactInfoResource;
}
// Local Cache to store contact document + info resources to avoid multiple DB calls in a single session.
const contactCache: Record<string, ContactBundle> = {};
function sortByPrimary<T extends { isPrimary: boolean }>(arr: T[]): T[] {
return [...arr].sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary));
}
type EmailRow = { email_id: string; isPrimary: boolean };
type PhoneRow = { phone: string; isPrimary: boolean };
// The single mapping from a Contact document to form-state rows. Kept in one
// place so the hydrate watch, resetState, and the dirty check can never drift.
function emailRowsFromDoc(doc?: Contact): EmailRow[] {
return sortByPrimary(
(doc?.email_ids || []).map((e: any) => ({
email_id: e.email_id,
isPrimary: Boolean(e.is_primary),
}))
);
}
function phoneRowsFromDoc(doc?: Contact): PhoneRow[] {
return sortByPrimary(
(doc?.phone_nos || []).map((p: any) => ({
phone: p.phone,
isPrimary: Boolean(p.is_primary_phone || p.is_primary_mobile_no),
}))
);
}
function customerLinksFromDoc(doc?: Contact): string[] {
return (doc?.links || []).map((l: any) => l.link_name as string);
}
let entryKeyCounter = 0;
export const nextEntryKey = () => ++entryKeyCounter;
const withKeys = <T>(rows: T[]) =>
rows.map((row) => ({ ...row, key: nextEntryKey() }));
// Hydrate the edit-form state from a Contact document.
function applyDocToState(state: EditContactState, doc?: Contact): void {
state.firstName = doc?.first_name || "";
state.lastName = doc?.last_name || "";
state.image = doc?.image || "";
state.emails = withKeys(emailRowsFromDoc(doc));
state.phones = withKeys(phoneRowsFromDoc(doc));
state.customers = customerLinksFromDoc(doc);
}
export function useContact(name: string) {
const router = useRouter();
const { doc, info: contactInfoResource } = findContact();
const { state, resetState } = useContactState(false, doc);
async function handleDelete({
deleteLinkedTickets,
}: {
deleteLinkedTickets: boolean;
}) {
try {
await call("helpdesk.api.contact.delete_contact", {
name,
delete_tickets: deleteLinkedTickets,
});
router.push({ name: "ContactList" });
} catch (err) {
getErrorMessage(err as Error, true);
throw err;
}
}
watch(
() => doc.doc,
(data) => {
if (!data) return;
applyDocToState(state, data);
},
{ immediate: true }
);
watch(
() => contactInfoResource.data,
(data) => {
state.timezone = data?.timezone || "";
},
{ immediate: true, deep: true }
);
const isContactInfoChanged = computed(() => {
return (
state.firstName !== (doc.doc?.first_name || "") ||
state.image !== (doc.doc?.image || "")
);
});
const isContactDirty = computed(() => {
const currentEmails = sortByPrimary(
state.emails.map((e) => ({
email_id: e.email_id,
isPrimary: e.isPrimary,
}))
);
const currentPhones = sortByPrimary(
state.phones
.filter((p) => p.phone)
.map((p) => ({ phone: p.phone, isPrimary: p.isPrimary }))
);
const savedTimezone = contactInfoResource.data?.timezone || "";
return (
state.firstName !== (doc.doc?.first_name || "") ||
state.lastName !== (doc.doc?.last_name || "") ||
state.image !== (doc.doc?.image || "") ||
state.timezone !== savedTimezone ||
JSON.stringify(currentEmails) !==
JSON.stringify(emailRowsFromDoc(doc.doc)) ||
JSON.stringify(currentPhones) !==
JSON.stringify(phoneRowsFromDoc(doc.doc))
);
});
// Selecting a customer to link counts as an unsaved change too.
const isDirty = computed(
() => isContactDirty.value || Boolean(state.customer)
);
function findContact(): ContactBundle {
if (contactCache.hasOwnProperty(name)) return contactCache[name];
const bundle: ContactBundle = {
doc: createDocumentResource({ doctype: "Contact", name }),
info: createResource({
url: "helpdesk.api.contact.get_contact_info",
method: "GET",
params: { name },
auto: true,
}),
};
contactCache[name] = bundle;
return bundle;
}
function parseContactData() {
return {
first_name: state.firstName,
last_name: state.lastName,
image: state.image,
email_ids: state.emails
.filter((e) => e.email_id)
.map((e) => ({
email_id: e.email_id,
is_primary: e.isPrimary,
})),
phone_nos: state.phones
.filter((p) => p.phone)
.map((p) => ({
phone: p.phone,
is_primary: p.isPrimary,
})),
timezone: state.timezone,
};
}
const editContactResource = createResource({
url: "helpdesk.api.contact.edit_contact",
method: "POST",
validate(data: { name: string; doc: ReturnType<typeof parseContactData> }) {
if (!data.doc.first_name?.trim()) {
throw new Error(__("First name is required"));
}
if (!data.doc.email_ids.length) {
throw new Error(__("At least one email address is required"));
}
for (const e of data.doc.email_ids) {
if (!validateEmailWithZod(e.email_id)) {
throw new Error(__("Invalid email address: {0}", [e.email_id]));
}
}
if (!data.doc.email_ids.some((e) => e.is_primary)) {
throw new Error(
__("At least one email address must be marked as primary")
);
}
},
onSuccess() {
resetState();
toast.success(__("Contact updated"));
},
});
// Links this contact to the selected customer
const linkCustomerResource = createResource({
url: "run_doc_method",
makeParams: () => ({
dt: "HD Customer",
dn: state.customer,
method: "add_contacts",
args: { contacts: JSON.stringify([name]), role: "HD Customer" },
}),
});
return {
doc,
contactInfoResource,
state,
resetState,
isContactInfoChanged,
isContactDirty,
isDirty,
parseContactData,
editContactResource,
linkCustomerResource,
handleDelete,
};
}
export function useNewContact() {
const { state, resetState } = useContactState(true);
const fieldConfig = getContactFieldConfig(true);
const router = useRouter();
const addContactResource = createResource({
url: "helpdesk.api.contact.create_contact",
method: "POST",
validate: (data: { doc: ReturnType<typeof parseContactData> }) => {
if (!data.doc.first_name) {
throw new Error(__("First name is required"));
}
if (!data.doc.email) {
throw new Error(__("Email address is required"));
}
if (!validateEmailWithZod(data.doc.email)) {
throw new Error(__("Invalid email address"));
}
},
onSuccess: (name: string) => {
router.push({ name: "Contact", params: { id: name } });
toast.success(
state.invite ? __("Contact created and invited") : __("Contact created")
);
resetState();
},
onError: (err: unknown) => {
getErrorMessage(err as Error, true);
},
});
function parseContactData() {
return {
first_name: state.firstName,
last_name: state.lastName,
image: state.image,
email: state.email,
phone: state.phone,
customer: state.customer,
timezone: state.timezone,
};
}
function addContact() {
return addContactResource.submit({
doc: parseContactData(),
invite: state.invite,
});
}
return {
state,
fieldConfig,
addContact,
isLoading: computed(() => addContactResource.loading),
reset: resetState,
};
}
// Function overloads to provide conditonal types for new vs existing contact states.
export function useContactState(newDoc: true): {
state: NewContactState;
resetState: () => void;
};
export function useContactState(
newDoc: false,
doc: DocumentResource<Contact>
): {
state: EditContactState;
resetState: () => void;
};
export function useContactState(
newDoc: boolean = false,
doc?: DocumentResource<Contact>
) {
if (newDoc) {
const state = reactive<NewContactState>({
firstName: "",
lastName: "",
image: "",
email: "",
phone: "",
timezone: "",
customer: "",
invite: true,
});
function resetState() {
state.firstName = "";
state.lastName = "";
state.image = "";
state.email = "";
state.phone = "";
state.timezone = "";
state.customer = "";
state.invite = false;
}
return { state, resetState };
} else {
const state = reactive<EditContactState>({
firstName: "",
lastName: "",
image: "",
emails: [],
phones: [],
customers: [],
customer: "",
timezone: "",
});
function resetState() {
const data = doc?.doc;
applyDocToState(state, data);
state.timezone = data?.timezone || "";
state.customer = "";
}
return { state, resetState };
}
}
interface FieldConfig {
key: string;
type: FieldType;
label: string;
required?: boolean;
placeholder?: string;
description?: string;
prefix?: ReturnType<typeof markRaw> | ReturnType<typeof h>;
doctype?: string;
options?: AutocompleteOption[];
}
type FieldConfigRow = FieldConfig | FieldConfig[];
function getContactFieldConfig(newDoc: boolean = false): FieldConfigRow[] {
const baseFields: FieldConfigRow[] = [
{
key: "image",
type: "file",
label: __("Profile Picture"),
required: false,
placeholder: __("Upload profile picture"),
},
[
{
key: "firstName",
type: "text",
label: __("First Name"),
required: true,
placeholder: __("John"),
},
{
key: "lastName",
type: "text",
label: __("Last Name"),
placeholder: __("Doe"),
},
],
];
const emailField: FieldConfig = newDoc
? {
key: "email",
type: "email",
label: __("Email"),
required: true,
placeholder: __("[email protected]"),
}
: {
key: "emails",
type: "email_ids",
label: __("Emails"),
};
const phoneField: FieldConfig = newDoc
? {
key: "phone",
type: "tel",
label: __("Phone"),
placeholder: __("123-456-7890"),
}
: {
key: "phones",
type: "phone_nos",
label: __("Phone Numbers"),
};
const restFields: FieldConfigRow[] = [
{
key: "customer",
type: "Link",
label: __("Customer"),
placeholder: __("Select Customer"),
doctype: "HD Customer",
},
];
return [...baseFields, emailField, phoneField, ...restFields];
}
export function useContactInvite() {
const isLoading = ref(false);
// @ts-expect-error
const { updateOnboardingStep } = useOnboarding("helpdesk");
async function resendInvite(
invitationName: string,
status: string | undefined,
contactName: string,
email: string | undefined
): Promise<void> {
if (status === "Expired") {
return inviteAsUser(contactName, email);
}
try {
isLoading.value = true;
await call("frappe.core.api.user_invitation.resend_invitation", {
name: invitationName,
app_name: "helpdesk",
});
toast.success(__("Invitation email resent successfully"));
updateOnboardingStep("add_invite_contact");
} catch (error: unknown) {
isLoading.value = false;
const parser = new DOMParser();
const doc = parser.parseFromString(
(error as Error).messages?.[0] || (error as Error).message,
"text/html"
);
const errMsg = doc.body.innerText;
toast.error(errMsg);
} finally {
isLoading.value = false;
}
}
async function inviteAsUser(
contactName: string,
email: string | undefined
): Promise<void> {
if (!email) {
toast.error(__("Contact has no email address to invite"));
return;
}
try {
isLoading.value = true;
await call("frappe.core.api.user_invitation.invite_by_email", {
emails: email,
roles: ["HD Customer"],
redirect_to_path: "/helpdesk",
app_name: "helpdesk",
contact: contactName,
});
toast.success(__("Invitation sent"));
updateOnboardingStep("add_invite_contact");
} catch (error: unknown) {
const parser = new DOMParser();
const doc = parser.parseFromString(
(error as Error).messages?.[0] || (error as Error).message,
"text/html"
);
const errMsg = doc.body.innerText;
toast.error(errMsg);
} finally {
isLoading.value = false;
}
}
return { resendInvite, inviteAsUser, isLoading };
}
export function useContactResetPassword(getUser: () => string | undefined) {
const isLoading = ref(false);
async function resetPassword(): Promise<void> {
const user = getUser();
if (!user) {
toast.error(__("No system user is linked with this contact"));
return;
}
try {
isLoading.value = true;
await call("frappe.core.doctype.user.user.reset_password", {
user,
});
toast.success(__("Reset password email sent successfully"));
} catch (error: unknown) {
const parser = new DOMParser();
const doc = parser.parseFromString(
(error as Error).messages?.[0] || (error as Error).message,
"text/html"
);
const errMsg = doc.body.innerText;
toast.error(errMsg);
} finally {
isLoading.value = false;
}
}
return { resetPassword, isLoading };
}
interface ContactFeedbackChartData {
average: number;
total: number;
data: Record<number, number>;
}
// key of feedback feedbackListResource which has type of ListResource<HDTicket> is feedbackListResource, key of count resource is Resource<number>
interface ContactFeedback {
feedbackListResource: ListResource<HDTicket>;
feedbackCount: ReturnType<typeof createResource>;
chartData: Resource<ContactFeedbackChartData>;
loading: ComputedRef<boolean>;
}
// Cache to avoid re-creating feedback resources for the same contact in a session
const contactFeedbackCache: Record<string, ContactFeedback> = {};
export function useContactFeedback(name: string): ContactFeedback {
if (contactFeedbackCache[name]) return contactFeedbackCache[name];
const feedbackListResource: ListResource<HDTicket> = createListResource({
doctype: "HD Ticket",
cache: [name, "FeedbackList"],
fields: [
"name",
"subject",
"feedback",
"feedback_rating",
"feedback_extra",
"creation",
"modified",
],
filters: { contact: name, feedback_rating: ["is", "set"] },
orderBy: "creation desc",
auto: true,
});
const feedbackCount = createResource({
url: "frappe.client.get_count",
makeParams: () => ({
doctype: "HD Ticket",
filters: {
contact: name,
feedback_rating: ["is", "set"],
},
}),
auto: true,
});
const chartData = createResource({
url: "helpdesk.api.ticket_stats.get_feedback_received",
makeParams: () => ({
scope: "contact",
value: name,
}),
auto: true,
});
const result: ContactFeedback = {
feedbackListResource,
feedbackCount,
chartData,
loading: computed(
() =>
feedbackCount.loading ||
feedbackListResource.loading ||
chartData.loading
),
};
contactFeedbackCache[name] = result;
return result;
}
+198
View File
@@ -0,0 +1,198 @@
import { __ } from "@/translation";
import { DocumentResource, Error } from "@/types";
import { HDCustomer } from "@/types/doctypes";
import { getErrorMessage } from "@/utils";
import { call, createDocumentResource } from "frappe-ui";
import { computed, h, markRaw, reactive, watch } from "vue";
import { useRouter } from "vue-router";
import LucideGlobe from "~icons/lucide/globe";
import LucideMapPin from "~icons/lucide/map-pin";
import LucideUser from "~icons/lucide/user";
import LucideUsers from "~icons/lucide/users";
import { OrganizationsIcon } from "../components/icons";
const customerCache: Record<string, DocumentResource<HDCustomer>> = {};
export function useCustomer(name: string) {
const router = useRouter();
const state = useCustomerState(name);
const doc = findCustomerDoc();
async function handleDelete({
deleteLinkedTickets,
}: {
deleteLinkedTickets: boolean;
}) {
try {
await call("helpdesk.api.customer.delete_customer", {
name,
delete_tickets: deleteLinkedTickets,
});
router.push({ name: "CustomerList" });
} catch (err) {
getErrorMessage(err as Error, true);
throw err;
}
}
watch(
() => doc.doc,
(data) => {
if (data) {
state.name = data.customer_name || "";
state.customerType = data.customer_type || "Company";
state.domain = data.domain || "";
state.image = data.image || "";
state.country = data.country || "";
}
},
{ immediate: true }
);
const isCustomerInfoChanged = computed(() => {
return (
state.domain !== (doc.doc?.domain || "") ||
state.image !== (doc.doc?.image || "") ||
state.country !== (doc.doc?.country || "") ||
state.customerType !== (doc.doc?.customer_type || "Company")
);
});
const hasNameChanged = computed(() => state.name !== doc.doc?.customer_name);
const isDirty = computed(() => {
return isCustomerInfoChanged.value || hasNameChanged.value || false;
});
function findCustomerDoc(): DocumentResource<HDCustomer> {
const key = name as string;
const cached = customerCache[key];
if (cached) return cached;
else {
const newDoc = createDocumentResource({
doctype: "HD Customer",
name: name,
whitelistedMethods: {
getContacts: "get_contacts",
addContacts: "add_contacts",
getPendingInvites: "get_pending_invites",
},
});
customerCache[key] = newDoc;
return newDoc;
}
}
return {
doc,
state,
isCustomerInfoChanged,
hasNameChanged,
isDirty,
handleDelete,
};
}
export function useCustomerState(name?: string | null) {
return reactive<Record<StateKey, string>>({
name: name ? name : "",
customerType: "Company",
domain: "",
image: "",
country: "",
});
}
function usePrimaryContactState() {
return reactive<Record<PrimaryContactKey, string>>({
firstName: "",
lastName: "",
email: "",
mobileNo: "",
});
}
/** Bundles the customer + primary-contact state for the New Customer dialog. */
export function useNewCustomerForm() {
const state = useCustomerState();
const primaryContact = usePrimaryContactState();
applyDefaults();
function reset() {
Object.assign(state, useCustomerState());
Object.assign(primaryContact, usePrimaryContactState());
applyDefaults();
}
function applyDefaults() {
state.country = window.default_country || "";
}
return { state, primaryContact, reset };
}
type StateKey = "name" | "customerType" | "domain" | "image" | "country";
type PrimaryContactKey = "firstName" | "lastName" | "email" | "mobileNo";
interface FieldConfig {
key: StateKey;
type: "text" | "select" | "textarea" | "checkbox" | "autocomplete" | "Link";
label: string;
required?: boolean;
placeholder?: string;
description?: string;
prefix?: ReturnType<typeof markRaw> | ReturnType<typeof h>;
doctype?: string; // only for Link type
options?: SelectOption[]; // only for select type
}
interface SelectOption {
label: string;
value: string;
icon?: ReturnType<typeof markRaw> | ReturnType<typeof h>;
}
export const customerTypeOptions: SelectOption[] = [
{
label: __("Company"),
value: "Company",
icon: markRaw(OrganizationsIcon),
},
{
label: __("Individual"),
value: "Individual",
icon: markRaw(LucideUser),
},
{
label: __("Partnership"),
value: "Partnership",
icon: markRaw(LucideUsers),
},
];
export const customerFields: FieldConfig[] = [
{
key: "name",
type: "text",
label: __("Name"),
required: true,
},
{
key: "customerType",
type: "select",
label: __("Customer Type"),
options: customerTypeOptions,
},
{
key: "country",
type: "Link",
label: __("Country"),
placeholder: __("Select Country"),
prefix: h(LucideMapPin, { class: "size-4 mr-1.5" }),
doctype: "Country",
},
{
key: "domain",
type: "text",
label: __("Domain"),
placeholder: "frappe.io",
prefix: h(LucideGlobe, { class: "size-4" }),
},
];
+1
View File
@@ -25,6 +25,7 @@ html,
body {
color: var(--ink-gray-9);
background-color: var(--surface-white);
overscroll-behavior: none;
}
:root {
+368
View File
@@ -0,0 +1,368 @@
<template>
<div class="flex flex-col max-w-screen-xl mx-auto w-full">
<LayoutHeader>
<template #left-header>
<Breadcrumbs :items="breadcrumbs" class="-ml-[2px]" />
</template>
</LayoutHeader>
<div
class="gap-5 flex flex-col h-full"
v-if="!contact.loading && contact.doc"
>
<!-- ContactInfo -->
<PageInfo
:avatar="{
label: `${contact.doc.full_name}`.trim(),
image: contact.doc.image ?? undefined,
shape: 'circle',
}"
:doc-info="contactInfo"
:badge="invitationBadge"
>
<template #actions>
<div class="flex gap-2 items-center">
<Button
variant="subtle"
@click="showEditDialog = true"
v-if="hasPermission()"
>
<div class="flex gap-1 items-center">
<LucideSquarePen class="h-4 w-4" />
<span>{{ __("Edit") }}</span>
</div>
</Button>
<Dropdown
:options="dropdownActions"
placement="right"
v-if="hasPermission()"
>
<Button icon="more-horizontal" variant="subtle" />
</Dropdown>
</div>
</template>
</PageInfo>
<div class="overflow-y-auto flex-1 flex flex-col">
<TicketStats :dt="'Contact'" :dn="id" v-if="!isMobileView" />
<Tabs v-model="activeTab" :tabs="tabs" class="tabs-sticky-header">
<template #tab-item="{ tab, selected }: any">
<button
class="group flex items-center gap-2 border-b border-transparent py-2 text-base text-ink-gray-5 duration-300 ease-in-out hover:text-ink-gray-9"
:class="{ 'text-ink-gray-9': selected }"
>
<component :is="tab.icon" v-if="tab.icon" class="h-5" />
{{ __(tab.label) }}
<Badge
class="group-hover:bg-surface-gray-7"
:class="[
selected
? '!bg-surface-gray-7'
: '!bg-surface-gray-2 !text-ink-gray-7',
]"
variant="solid"
theme="gray"
size="sm"
>
{{ tab.count }}
</Badge>
</button>
</template>
<template #tab-panel="{ tab }">
<div class="p-5 overflow-hidden flex flex-col flex-1 min-h-0">
<TicketsTab
v-if="tab.label === __('Tickets')"
:doc="contact"
:ticketsListResource="ticketsListResource"
:baseFilter="{ contact: props.id }"
:additionalFilter="
(contactInfoResource.data?.customers?.length ?? 0) > 1
? {
key: 'customer',
placeholder: __('Customer'),
doctype: 'HD Customer',
filters: {
name: ['in', contactInfoResource.data.customers.map((c: { name: string }) => c.name)],
},
}
: undefined
"
/>
<div v-if="tab.label === __('Feedback')">
<!-- Feedback tab content -->
<ContactFeedback :name="props.id" />
</div>
</div>
</template>
</Tabs>
</div>
</div>
<DeleteWithTicketsDialog
v-model="showDeleteDialog"
:name="id"
link-field="contact"
:message="
__(
'Are you sure you want to delete this contact? The contact will be permanently deleted and unlinked from any associated customers and tickets.'
)
"
:on-delete="handleDelete"
/>
<EditContactDialog
v-if="showEditDialog"
v-model="showEditDialog"
:name="id"
/>
</div>
</template>
<script setup lang="ts">
import ContactCustomers from "@/components/contact/ContactCustomers.vue";
import ContactFeedback from "@/components/contact/ContactFeedback.vue";
import TicketsTab from "@/components/customer/TicketsTab.vue";
import DeleteWithTicketsDialog from "@/components/DeleteWithTicketsDialog.vue";
import ModifiedIcon from "@/components/icons/ModifiedIcon.vue";
import TicketFeedbackIcon from "@/components/icons/TicketFeedbackIcon.vue";
import TicketHashIcon from "@/components/icons/TicketHashIcon.vue";
//@ts-ignore
import EditContactDialog from "@/components/contact/EditContactDialog.vue";
import LayoutHeader from "@/components/LayoutHeader.vue";
import PageInfo from "@/components/PageInfo.vue";
import {
useContact,
useContactFeedback,
useContactInvite,
useContactResetPassword,
} from "@/composables/contact";
import { useScreenSize } from "@/composables/screen";
import { __ } from "@/translation";
import { hasPermission } from "@/utils";
import {
Breadcrumbs,
Button,
Dropdown,
Tabs,
dayjs,
usePageMeta,
} from "frappe-ui";
import { computed, h, markRaw, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import LucideMail from "~icons/lucide/mail";
import LucideMapPin from "~icons/lucide/map-pin";
import LucidePhone from "~icons/lucide/phone";
import LucideTrash2 from "~icons/lucide/trash-2";
import { getTicketListResource } from "../../stores/docTickets";
const props = defineProps<{
id: string;
}>();
const route = useRoute();
const router = useRouter();
const { isMobileView } = useScreenSize();
const {
doc: contact,
contactInfoResource,
handleDelete,
} = useContact(props.id);
const { feedbackCount } = useContactFeedback(props.id);
const { ticketsListResource } = getTicketListResource();
const tabs = computed(() => [
{
label: __("Tickets"),
hash: "tickets",
count: ticketsListResource.data?.length ?? 0,
icon: h(TicketHashIcon, { class: "size-4" }),
},
{
label: __("Feedback"),
hash: "feedback",
count: feedbackCount.data ?? 0,
icon: h(TicketFeedbackIcon, { class: "size-4" }),
},
]);
const activeTab = computed<number>({
get() {
const index = tabs.value.findIndex((t) => t.hash === route.hash.slice(1));
return index === -1 ? 0 : index;
},
set(i) {
router.replace({ hash: i === 0 ? "" : `#${tabs.value[i].hash}` });
},
});
const contactInfo = computed(() => {
if (
contactInfoResource.loading ||
!contactInfoResource.data ||
!contact.doc?.name
) {
return [];
}
const info = [
{
icon: markRaw(LucideMail),
value: contact.doc?.email_id,
condition: !!contact.doc?.email_id,
},
{
icon: markRaw(LucidePhone),
value: contact.doc?.mobile_no,
condition: !!contact.doc?.mobile_no,
},
{
icon: markRaw(LucideMapPin),
value: contactInfoResource.data?.country,
condition: !!contactInfoResource.data?.country,
},
{
icon: markRaw(ModifiedIcon),
value: __("Last seen {0}", [
dayjs(contactInfoResource.data?.last_seen).fromNow(),
]),
condition: !!contactInfoResource.data?.last_seen,
},
{
component: markRaw(
h(ContactCustomers, {
customers: contactInfoResource.data?.customers ?? [],
})
),
condition: (contactInfoResource.data?.customers?.length ?? 0) > 0,
},
];
return info;
});
const {
resendInvite,
inviteAsUser,
isLoading: isContactInvitationLoading,
} = useContactInvite();
const { resetPassword, isLoading: isResetPasswordLoading } =
useContactResetPassword(() => contact.doc?.user);
const showEditDialog = ref(false);
const invitation = computed(() => contactInfoResource.data?.invitation);
const invitationBadge = computed(() => {
const inv = invitation.value;
if (!inv?.name) return null;
if (inv.status === "Expired") {
return {
label: __("Invitation Expired"),
theme: "red" as const,
tooltip: __("Invitation expired. Resend to invite again."),
};
}
return {
label: __("Invited"),
theme: "orange" as const,
tooltip: __("Invite sent. Waiting for the user to accept."),
};
});
const showDeleteDialog = ref(false);
const dropdownActions = computed(() => {
const baseActions = [];
if (!contact.doc?.user && !invitation.value?.name) {
baseActions.push({
label: __("Invite as User"),
icon: "user-plus",
onClick: async () => {
await inviteAsUser(props.id, contact.doc?.email_id);
contactInfoResource.reload();
},
});
}
if (invitation.value?.name) {
baseActions.push({
label: __("Resend Invite"),
icon: "mail",
onClick: async () => {
await resendInvite(
invitation.value!.name,
invitation.value!.status,
props.id,
contact.doc?.email_id
);
contactInfoResource.reload();
},
});
}
if (contact.doc?.user) {
baseActions.push({
label: __("Send reset password email"),
icon: "mail",
onClick: () => {
resetPassword();
},
});
}
return [
{
group: __("Actions"),
hideLabel: true,
items: baseActions,
},
{
group: __("Danger"),
hideLabel: true,
items: [
{
label: __("Delete"),
icon: LucideTrash2,
theme: "red",
onClick: () => {
showDeleteDialog.value = true;
},
},
],
},
];
});
const breadcrumbs = [
{
label: __("Contacts"),
route: { name: "ContactList" },
},
{
label: props.id,
},
];
onMounted(() => {
ticketsListResource.update({
filters: {
contact: props.id,
},
});
ticketsListResource.fetch();
});
usePageMeta(() => {
return {
title: `Contact: ${props.id}`,
};
});
</script>
<style scoped>
/* frappe-ui's TabsRoot clips with overflow-hidden, which traps the sticky
tablist. Let it overflow so the tablist sticks to the page scroll container. */
.tabs-sticky-header {
overflow: visible !important;
}
.tabs-sticky-header :deep([role="tablist"]) {
position: sticky;
top: 0;
z-index: 10;
background-color: white;
}
</style>
@@ -2,11 +2,13 @@
<div class="flex flex-col">
<LayoutHeader>
<template #left-header>
<div class="text-lg font-medium text-ink-gray-9">Contacts</div>
<div class="text-lg font-medium text-ink-gray-9">
{{ __("Contacts") }}
</div>
</template>
<template #right-header>
<Button
label="Create"
:label="__('Create')"
theme="gray"
variant="solid"
@click="showNewContactModal = !showNewContactModal"
@@ -17,38 +19,20 @@
</Button>
</template>
</LayoutHeader>
<ListViewBuilder
ref="listViewRef"
:options="options"
@row-click="openContact"
@empty-state-action="showNewContactModal = true"
/>
<NewContactDialog
v-model="showNewContactModal"
@contact-created="handleContactCreated"
/>
<ContactDialog
v-if="isContactDialogVisible"
:key="selectedContact"
v-model="isContactDialogVisible"
:name="selectedContact"
@contact-updated="handleContactUpdated"
/>
<ListViewBuilder ref="listViewRef" :options="options" />
<NewContactDialog v-model="showNewContactModal" />
</div>
</template>
<script setup lang="ts">
import { LayoutHeader, ListViewBuilder } from "@/components";
import NewContactDialog from "@/components/desk/global/NewContactDialog.vue";
import NewContactDialog from "@/components/contact/NewContactDialog.vue";
import { PhoneIcon } from "@/components/icons";
import { Avatar, toast, usePageMeta } from "frappe-ui";
import { computed, h, ref } from "vue";
import ContactDialog from "./ContactDialog.vue";
import { showNewContactModal } from "./dialogState";
import LucideContact2 from "~icons/lucide/contact-2";
import { __ } from "@/translation";
import { Avatar, usePageMeta } from "frappe-ui";
import { computed, h, ref } from "vue";
import LucideContact2 from "~icons/lucide/contact-2";
import { showNewContactModal } from "./dialogState";
const isContactDialogVisible = ref(false);
const selectedContact = ref(null);
const hasActiveFilters = computed(
() => Object.keys(listViewRef.value?.list?.params?.filters || {}).length > 0
);
@@ -84,23 +68,13 @@ const options = computed(() => {
)
: undefined,
},
rowRoute: {
name: "Contact",
prop: "id",
},
};
});
function handleContactCreated(): void {
showNewContactModal.value = false;
listViewRef.value?.reload();
}
function openContact(id: string): void {
selectedContact.value = id;
isContactDialogVisible.value = true;
}
function handleContactUpdated(): void {
toast.success("Contact updated successfully.");
listViewRef.value?.reload();
}
usePageMeta(() => {
return {
title: "Contacts",
+282
View File
@@ -0,0 +1,282 @@
<template>
<div class="flex flex-col overflow-y-hidden max-w-screen-xl mx-auto w-full">
<LayoutHeader>
<template #left-header>
<Breadcrumbs :items="breadcrumbs" class="-ml-[2px]" />
</template>
</LayoutHeader>
<div
class="gap-5 flex flex-col h-full"
v-if="customer.doc && customer.doc?.name"
>
<!-- customer detail -->
<PageInfo
:avatar="{
label: customer.doc.customer_name ?? '',
image: customer.doc.image,
shape: 'square',
}"
:docInfo="customerInfo"
>
<template #actions>
<div v-if="hasPermission()" class="flex gap-2 items-center">
<Button variant="subtle" @click="customerDialog = true">
<div class="flex gap-1 items-center">
<LucideSquarePen class="h-4 w-4" />
<span>{{ __("Edit") }}</span>
</div>
</Button>
<Dropdown :options="dropdownActions" placement="right">
<Button icon="more-horizontal" variant="subtle" />
</Dropdown>
</div>
</template>
</PageInfo>
<div class="overflow-y-auto flex-1 flex flex-col">
<TicketStats
:dt="'HD Customer'"
:dn="customer.doc.name"
v-if="!isMobileView"
/>
<!-- Tabs -->
<Tabs v-model="activeTab" :tabs="tabs" class="tabs-sticky-header">
<template #tab-item="{ tab, selected }">
<button
class="group flex items-center gap-2 border-b border-transparent py-2 text-base text-ink-gray-5 duration-300 ease-in-out hover:text-ink-gray-9"
:class="{ 'text-ink-gray-9': selected }"
>
<component :is="tab.icon" v-if="tab.icon" class="h-5" />
{{ __(tab.label) }}
<Badge
class="group-hover:bg-surface-gray-7"
:class="[
selected
? '!bg-surface-gray-7'
: '!bg-surface-gray-2 !text-ink-gray-7',
]"
variant="solid"
theme="gray"
size="sm"
>
{{ tab.count }}
</Badge>
</button>
</template>
<template #tab-panel="{ tab }">
<div class="p-5 overflow-hidden flex flex-col flex-1 min-h-0">
<div v-if="tab.label === __('Tickets')">
<!-- Tickets tab content -->
<TicketsTab
:doc="customer"
:ticketsListResource="ticketsListResource"
:baseFilter="{ customer: props.id }"
:additionalFilter="contactFilter"
/>
</div>
<CustomerContactTab v-if="tab.label === __('Contacts')" />
</div>
</template>
</Tabs>
</div>
</div>
</div>
<EditCustomerDialog
v-if="customerDialog"
v-model="customerDialog"
@update="customerDialog = false"
:id="id"
/>
<DeleteWithTicketsDialog
v-model="showDeleteDialog"
:name="id"
link-field="customer"
:title="__('Delete Contact')"
:message="
__(
'Are you sure you want to delete this customer? The reference to this customer will be removed from all the related tickets.'
)
"
:on-delete="handleDelete"
/>
</template>
<script setup lang="ts">
import CustomerContactTab from "@/components/customer/CustomerContactTab.vue";
import TicketsTab from "@/components/customer/TicketsTab.vue";
import TicketStats from "@/components/customer/TicketStats.vue";
import DeleteWithTicketsDialog from "@/components/DeleteWithTicketsDialog.vue";
import TicketHashIcon from "@/components/icons/TicketHashIcon.vue";
import LayoutHeader from "@/components/LayoutHeader.vue";
import PageInfo from "@/components/PageInfo.vue";
import { useCustomer } from "@/composables/customer";
import { useScreenSize } from "@/composables/screen";
import { __ } from "@/translation";
import { CustomerResourceSymbol } from "@/types";
import { hasPermission } from "@/utils";
import {
Badge,
Breadcrumbs,
Button,
Dropdown,
Tabs,
usePageMeta,
} from "frappe-ui";
import { computed, h, markRaw, onMounted, provide, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import LucideGlobe from "~icons/lucide/globe";
import LucideMail from "~icons/lucide/mail";
import LucideMapPin from "~icons/lucide/map-pin";
import LucidePhone from "~icons/lucide/phone";
import LucideSquarePen from "~icons/lucide/square-pen";
import LucideSquareUser from "~icons/lucide/square-user";
import LucideTrash2 from "~icons/lucide/trash-2";
import { getTicketListResource } from "../../stores/docTickets";
// props with type set at string
const props = defineProps<{
id: string;
}>();
const route = useRoute();
const router = useRouter();
const { isMobileView } = useScreenSize();
const { ticketsListResource } = getTicketListResource();
const tabs = computed(() => [
{
label: __("Tickets"),
hash: "tickets",
count: ticketsListResource.data?.length ?? 0,
icon: h(TicketHashIcon, { class: "size-4" }),
},
{
label: __("Contacts"),
hash: "contacts",
count: customer.getContacts.loading
? 0
: customer.getContacts.data?.length ?? 0,
icon: h(LucideSquareUser, { class: "size-4" }),
},
]);
const { doc: customer, handleDelete } = useCustomer(props.id);
provide(CustomerResourceSymbol, customer);
const contactFilter = computed(() => {
const contacts = customer.getContacts.data ?? [];
if (contacts.length <= 1) return undefined;
return {
key: "contact",
placeholder: __("Contact"),
doctype: "Contact",
filters: {
name: [
"in",
contacts.map((c: { contact_name: string }) => c.contact_name),
],
},
};
});
const activeTab = computed<number>({
get() {
const index = tabs.value.findIndex((t) => t.hash === route.hash.slice(1));
if (index === -1) {
router.replace({ hash: "" });
return 0;
}
return index;
},
set(i) {
if (i === 0) {
router.replace({ hash: "" });
return;
}
router.replace({ hash: `#${tabs.value[i].hash}` });
},
});
const breadcrumbs = [
{
label: __("Customers"),
route: { name: "CustomerList" },
},
{
label: props.id,
},
];
const customerDialog = ref(false);
const showDeleteDialog = ref(false);
const dropdownActions = computed(() => [
{
group: __("Danger"),
hideLabel: true,
items: [
{
label: __("Delete"),
icon: LucideTrash2,
theme: "red",
onClick: () => {
showDeleteDialog.value = true;
},
},
],
},
]);
const customerInfo = computed(() => [
{
icon: markRaw(LucideGlobe),
value: customer.doc.domain,
condition: !!customer.doc.domain,
},
{
icon: markRaw(LucideMail),
value: customer.doc.email_id,
condition: !!customer.doc.email_id,
},
{
icon: markRaw(LucidePhone),
value: customer.doc.mobile_no,
condition: !!customer.doc.mobile_no,
},
{
icon: markRaw(LucideMapPin),
value: customer.doc.country,
condition: !!customer.doc.country,
},
]);
onMounted(() => {
if (hasPermission()) {
customer.getPendingInvites.fetch();
}
customer.getContacts.fetch();
ticketsListResource.update({
filters: {
customer: props.id,
},
});
ticketsListResource.fetch();
});
usePageMeta(() => {
return {
title: `Customer: ${props.id}`,
};
});
</script>
<style scoped>
/* frappe-ui's TabsRoot clips with overflow-hidden, which traps the sticky
tablist. Let it overflow so the tablist sticks to the page scroll container. */
.tabs-sticky-header {
overflow: visible !important;
}
.tabs-sticky-header :deep([role="tablist"]) {
position: sticky;
top: 0;
z-index: 10;
background-color: white;
}
</style>
@@ -2,11 +2,13 @@
<div class="flex flex-col">
<LayoutHeader>
<template #left-header>
<div class="text-lg font-medium text-ink-gray-9">Customers</div>
<div class="text-lg font-medium text-ink-gray-9">
{{ __("Customers") }}
</div>
</template>
<template #right-header>
<Button
label="Create"
:label="__('Create')"
theme="gray"
variant="solid"
class="rtl:flex-row-reverse"
@@ -18,56 +20,29 @@
</Button>
</template>
</LayoutHeader>
<ListViewBuilder
ref="listViewRef"
:options="options"
@row-click="openCustomer"
@empty-state-action="isDialogVisible = true"
/>
<NewCustomerDialog
v-model="isDialogVisible"
@customer-created="handleCustomer"
/>
<span v-if="isCustomerDialogVisible">
<CustomerDialog
v-model="isCustomerDialogVisible"
:name="selectedCustomer"
@customer-updated="handleCustomer(true)"
/>
</span>
<ListViewBuilder :options="options" />
<NewCustomerDialog v-model="isDialogVisible" />
</div>
</template>
<script setup lang="ts">
import LayoutHeader from "@/components/LayoutHeader.vue";
import ListViewBuilder from "@/components/ListViewBuilder.vue";
import NewCustomerDialog from "@/components/desk/global/NewCustomerDialog.vue";
import { OrganizationsIcon } from "@/components/icons";
import NewCustomerDialog from "@/components/customer/NewCustomerDialog.vue";
import { OrganizationsIcon, PhoneIcon } from "@/components/icons";
import { __ } from "@/translation";
import { Avatar, usePageMeta } from "frappe-ui";
import { computed, h, ref } from "vue";
import CustomerDialog from "./CustomerDialog.vue";
const isDialogVisible = ref(false);
const isCustomerDialogVisible = ref(false);
const selectedCustomer = ref(null);
const listViewRef = ref<InstanceType<typeof ListViewBuilder> | null>(null);
const listViewRef = ref(null);
// const emptyMessage = "No Customers Found";
const hasActiveFilters = computed(
() => Object.keys(listViewRef.value?.list?.params?.filters || {}).length > 0
);
function openCustomer(id: string) {
selectedCustomer.value = id;
isCustomerDialogVisible.value = true;
}
function handleCustomer(updated = false) {
updated
? (isCustomerDialogVisible.value = false)
: (isDialogVisible.value = false);
listViewRef.value?.reload();
}
const options = computed(() => {
return {
doctype: "HD Customer",
@@ -84,6 +59,9 @@ const options = computed(() => {
});
},
},
mobile_no: {
prefix: PhoneIcon,
},
},
emptyState: {
title: "No customers found",
@@ -96,6 +74,10 @@ const options = computed(() => {
class: "h-10 w-10",
}),
},
rowRoute: {
name: "Customer",
prop: "id",
},
};
});
@@ -1,317 +0,0 @@
<template>
<Dialog bare>
<div class="flex flex-col items-center gap-4 p-6">
<div class="text-xl font-medium text-ink-gray-9">
{{ contact.doc?.full_name }}
</div>
<Avatar
size="2xl"
:label="contact.doc?.full_name"
:image="contact.doc?.image"
class="cursor-pointer hover:opacity-80"
/>
<div class="flex gap-2">
<FileUploader
:validate-file="validateFile"
@success="(file:File) => updateImage(file)"
>
<template #default="{ uploading, openFileSelector }">
<Button
:label="
contact.doc?.image ? __('Change photo') : __('Upload photo')
"
:loading="uploading"
@click="openFileSelector"
/>
</template>
</FileUploader>
<Button
v-if="contact.doc?.image"
:label="__('Remove photo')"
@click="updateImage(null)"
/>
<Button
v-if="!contact.doc?.user && isManager"
:label="__('Invite as user')"
@click="inviteContact"
:loading="isLoading"
/>
</div>
<div class="w-full space-y-2 text-sm text-ink-gray-7">
<div class="space-y-1">
<div class="text-xs">{{ __("Emails") }}</div>
<MultiSelect
v-model:items="emails"
placeholder="[email protected]"
:validate="validateEmail"
/>
</div>
<div class="space-y-1">
<div class="text-xs">{{ __("Phone Nos") }}</div>
<MultiSelect
v-model:items="phones"
placeholder="+91 98765 43210"
:validate="validatePhone"
/>
</div>
<div class="space-y-1">
<div class="text-xs">{{ __("Customer") }}</div>
<Link
doctype="HD Customer"
class="form-control flex-1"
:placeholder="__('Link to a customer')"
v-model="selectedCustomer"
:hide-me="true"
/>
</div>
</div>
<Button
class="w-full"
variant="solid"
theme="gray"
:label="__('Save')"
:loading="contact.setValue.loading"
@click="update"
/>
</div>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
call,
createDocumentResource,
createListResource,
Dialog,
FileUploader,
toast,
} from "frappe-ui";
import { useOnboarding } from "frappe-ui/frappe";
import type { Ref } from "vue";
import { computed, ref } from "vue";
import zod from "zod";
import { __ } from "@/translation";
import Link from "@/components/frappe-ui/Link.vue";
import MultiSelect from "@/components/MultiSelect.vue";
import { useAuthStore } from "@/stores/auth";
import { AutoCompleteItem, File } from "@/types";
interface Props {
name: {
type: string;
required: true;
};
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: "contactUpdated"): void;
}>();
interface Email {
email_id: string;
is_primary?: boolean;
}
interface Phone {
phone: string;
is_primary_phone?: boolean;
is_primary_mobile?: boolean;
}
const isDirty: Ref<boolean> = ref(false);
const emails = computed({
get() {
const emails = contact.doc?.email_ids || [];
return emails.map((e: Email) => ({
label: e.email_id,
value: e.email_id,
}));
},
set(newVal) {
if (newVal.length === 0) {
toast.error(__("At least one email is required"));
return;
}
if (newVal.length !== contact.doc.email_ids.length) {
isDirty.value = true;
}
contact.doc.email_ids = newVal.map((email: AutoCompleteItem) => ({
email_id: email.value,
}));
},
});
const phones = computed({
get() {
const phone_nos = contact.doc?.phone_nos || [];
return phone_nos.map((e: Phone) => ({
label: e.phone,
value: e.phone,
}));
},
set(newVal) {
if (newVal.length !== contact.doc.phone_nos.length) {
isDirty.value = true;
}
contact.doc.phone_nos = newVal.map((item: AutoCompleteItem) => ({
phone: item.value,
}));
},
});
const selectedCustomer = computed({
get() {
const customerLink = contact.doc?.links?.find(
(link) => link.link_doctype === "HD Customer"
);
return customerLink?.link_name || null;
},
set(value) {
const currentCustomer = contact.doc?.links?.find(
(link) => link.link_doctype === "HD Customer"
)?.link_name;
if (value !== currentCustomer) {
if (value) {
const existingCustomerLinkIndex = contact.doc.links?.findIndex(
(link) => link.link_doctype === "HD Customer"
);
if (existingCustomerLinkIndex !== -1) {
contact.doc.links[existingCustomerLinkIndex].link_name = value;
} else {
contact.doc.links.push({
link_doctype: "HD Customer",
link_name: value,
});
}
} else {
contact.doc.links = contact.doc.links?.filter(
(link) => link.link_doctype !== "HD Customer"
);
}
isDirty.value = true;
}
},
});
const customerResource = createListResource({
doctype: "HD Customer",
fields: ["name"],
cache: "customers",
transform: (data) => {
return data.map((option) => ({
label: option.name,
value: option.name,
}));
},
auto: true,
});
function handleCustomerChange(item: AutoCompleteItem | null) {
if (!item || item.label === "No label") {
selectedCustomer.value = null;
} else {
selectedCustomer.value = item.value;
}
}
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { isManager } = useAuthStore();
const contact = createDocumentResource({
doctype: "Contact",
name: props.name,
auto: true,
setValue: {
onSuccess() {
emit("contactUpdated");
},
},
});
function update(): void {
if (!isDirty.value) {
toast.error(__("No changes to save"));
return;
}
contact.setValue.submit({
email_ids: emails.value.map((email: AutoCompleteItem) => ({
email_id: email.value,
is_primary: email.value === contact.doc.email_id,
})),
phone_nos: phones.value.map((phoneNum: AutoCompleteItem) => ({
phone: phoneNum.value,
is_primary_phone: phoneNum.value === contact.doc.phone,
is_primary_mobile: phoneNum.value === contact.doc.phone,
})),
links: contact.doc.links,
});
isDirty.value = false;
emit("contactUpdated");
}
function updateImage(file: File): void {
contact.setValue.submit({
image: file?.file_url || null,
});
isDirty.value = true;
}
function validateEmail(input: AutoCompleteItem): string | void {
const success = zod.string().email().safeParse(input.value).success;
if (!success) return __("Invalid email");
}
function validatePhone(input: AutoCompleteItem): string | void {
const success = zod
.string()
.regex(/^\+[1-9]\d{1,14}$/)
.min(10)
.max(15)
.safeParse(input.value).success;
if (!success) return __("Invalid phone number");
}
function validateFile(file: File): string | void {
let extn = file.name.split(".").pop().toLowerCase();
if (!["png", "jpg", "jpeg"].includes(extn)) {
toast.error(__("Invalid file type, only PNG and JPG images are allowed"));
return __("Invalid file type, only PNG and JPG images are allowed");
}
}
const isLoading = ref(false);
async function inviteContact(): Promise<void> {
try {
isLoading.value = true;
const user = await call(
"frappe.contacts.doctype.contact.contact.invite_user",
{
contact: contact.doc.name,
}
);
toast.success(__("Contact invited successfully."));
await contact.setValue.submit({
user: user,
});
updateOnboardingStep("add_invite_contact");
} catch (error) {
isLoading.value = false;
const parser = new DOMParser();
const doc = parser.parseFromString(
error.messages?.[0] || error.message,
"text/html"
);
const errMsg = doc.body.innerText;
toast.error(errMsg);
} finally {
isLoading.value = false;
}
}
</script>
@@ -1,103 +0,0 @@
<template>
<Dialog bare>
<div class="flex flex-col items-center gap-4 p-6">
<div class="text-xl font-medium text-ink-gray-9">
{{ customer.doc?.name }}
</div>
<Avatar
size="lg"
:label="customer.doc?.name"
:image="customer.doc?.image"
class="cursor-pointer hover:opacity-80"
/>
<div class="flex gap-2">
<FileUploader @success="(file) => updateImage(file)">
<template #default="{ uploading, openFileSelector }">
<Button
:label="
customer.doc?.image ? __('Change photo') : __('Upload photo')
"
:loading="uploading"
@click="openFileSelector"
/>
</template>
</FileUploader>
<Button
v-if="customer.doc?.image"
:label="__('Remove photo')"
@click="updateImage(null)"
/>
</div>
<form class="w-full space-y-4" @submit.prevent="update">
<TextInput v-model="domain" label="Domain" placeholder="example.com" />
<Button
type="button"
class="w-full"
variant="solid"
theme="gray"
:label="__('Save')"
:loading="customer.setValue.loading"
@click="update"
/>
</form>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import {
Avatar,
createDocumentResource,
Dialog,
FileUploader,
toast,
} from "frappe-ui";
import { computed } from "vue";
const props = defineProps({
name: {
type: String,
required: true,
},
});
const emit = defineEmits(["customer-updated"]);
const domain = computed({
get() {
return customer.doc?.domain;
},
set(d: string) {
customer.doc.domain = d;
},
});
const customer = createDocumentResource({
doctype: "HD Customer",
name: props.name,
auto: true,
setValue: {
onSuccess() {
toast.success(__("Customer updated successfully."));
},
onError() {
toast.error(__("Error updating customer"));
},
},
});
async function update() {
await customer.setValue.submit({
domain: domain.value,
});
emit("customer-updated");
}
function updateImage(file) {
customer.setValue.submit({
image: file?.file_url || null,
});
emit("customer-updated");
}
</script>
@@ -12,22 +12,17 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, type PropType } from "vue";
import CardBase from "./CardBase.vue";
import { createResource } from "frappe-ui";
import { __ } from "@/translation";
import { EChartsOption } from "echarts";
import type { LineChart } from "@/types";
import { buildPercentageChange } from "@/utils";
interface Data {
percentage_change: number;
total: number;
data: { date: string; count: number }[];
}
import { EChartsOption } from "echarts";
import { createResource } from "frappe-ui";
import { computed, onMounted, ref, type PropType } from "vue";
import CardBase from "../../../components/ChartCardBase.vue";
const props = defineProps({
data: {
type: Object as PropType<Data>,
type: Object as PropType<LineChart>,
required: true,
},
});
@@ -40,14 +35,15 @@ const chartColor = {
};
const chartData = computed(() => {
const _data: Data = getAgentTicketsResource.fetched
const _data: LineChart = getAgentTicketsResource.fetched
? getAgentTicketsResource.data
: props.data;
const _percentageChange = _data?.percentage_change ?? null;
const _percentageChange = _data?.percentage_change || 0;
const total = _data?.total || 0;
const dates = _data?.data?.map((item) => item.date) || [];
const counts = _data?.data?.map((item) => item.count) || [];
const percentageChange = buildPercentageChange(_percentageChange);
return {
+8 -8
View File
@@ -1,7 +1,7 @@
<template>
<div class="h-full w-full rounded border border-outline-gray-1">
<AgentTicketsCard v-if="item.chart == 'agent_tickets'" :data="item.data" />
<AvgTimeCard
<LineChartCard
v-if="item.chart == 'avg_first_response_time'"
:title="__('Average First Response')"
api-url="helpdesk.api.agent_home.agent_home.get_avg_first_response_time"
@@ -11,7 +11,7 @@
gradientColor: { start: '#ee9d9f', end: 'rgba(251,232,233,0)' },
}"
/>
<AvgTimeCard
<LineChartCard
v-if="item.chart == 'avg_resolution_time'"
:title="__('Average Resolution')"
api-url="helpdesk.api.agent_home.agent_home.get_avg_resolution_time"
@@ -28,13 +28,13 @@
</template>
<script setup lang="ts">
import { type PropType } from "vue";
import AvgTimeCard from "./AvgTimeCard.vue";
import AvgTimeMetrics from "./AvgTimeMetrics.vue";
import RecentFeedback from "./RecentFeedback.vue";
import PendingTickets from "./PendingTickets.vue";
import AgentTicketsCard from "./AgentTicketsCard.vue";
import { __ } from "@/translation";
import { type PropType } from "vue";
import LineChartCard from "../../../components/LineChartCard.vue";
import AgentTicketsCard from "./AgentTicketsCard.vue";
import AvgTimeMetrics from "./AvgTimeMetrics.vue";
import PendingTickets from "./PendingTickets.vue";
import RecentFeedback from "./RecentFeedback.vue";
interface Item {
chart: string;
+14 -2
View File
@@ -84,12 +84,24 @@ const routes = [
{
path: "/customers",
name: "CustomerList",
component: () => import("@/pages/desk/customer/Customers.vue"),
component: () => import("@/pages/customer/Customers.vue"),
},
{
path: "/customers/:id",
name: "Customer",
component: () => import("@/pages/customer/Customer.vue"),
props: true,
},
{
path: "/contacts",
name: "ContactList",
component: () => import("@/pages/desk/contact/Contacts.vue"),
component: () => import("@/pages/contact/Contacts.vue"),
},
{
path: "/contacts/:id",
name: "Contact",
component: () => import("@/pages/contact/Contact.vue"),
props: true,
},
{
path: "/agents",
-28
View File
@@ -1,28 +0,0 @@
import { computed, ComputedRef } from "vue";
import { defineStore } from "pinia";
import { createListResource } from "frappe-ui";
type Contact = {
name: string;
first_name: string;
last_name: string;
full_name: string;
email_id: string;
};
export const useContactStore = defineStore("contact", () => {
const d__ = createListResource({
doctype: "Contact",
fields: ["*"],
auto: true,
pageLength: 99999,
});
const options: ComputedRef<Array<Contact>> = computed(
() => d__.list?.data || []
);
return {
options,
};
});
+26
View File
@@ -0,0 +1,26 @@
import type { ListResource } from "@/types";
import type { HDTicket } from "@/types/doctypes";
import { createListResource } from "frappe-ui";
export function getTicketListResource(): {
ticketsListResource: ListResource<HDTicket>;
} {
return {
ticketsListResource: createListResource({
doctype: "HD Ticket",
pageLength: 20,
fields: [
"name",
"subject",
"status",
"priority",
"creation",
"modified",
"_assign",
"response_by",
"resolution_by",
],
orderBy: "modified desc",
}),
};
}
+7 -2
View File
@@ -1,6 +1,11 @@
import { Extension, generateJSON } from "@tiptap/core";
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
import { TextAlign } from "@tiptap/extension-text-align";
import {
Table,
TableCell,
TableHeader,
TableRow,
} from "@tiptap/extension-table";
import TextAlign from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import StarterKit from "@tiptap/starter-kit";
+58 -2
View File
@@ -1,6 +1,6 @@
import { Dayjs } from "dayjs";
import { Component, ComputedRef, InjectionKey, Ref } from "vue";
import type { AssignmentRule, HDTicket } from "./types/doctypes";
import type { AssignmentRule, HDCustomer, HDTicket } from "./types/doctypes";
interface ResourceOptions<T = any> {
method?: string;
@@ -118,6 +118,7 @@ export interface ListResource<T = any> {
setValue: Resource<T>;
delete: Resource<T>;
runDocMethod: Resource<T>;
loading: boolean;
update: (updatedOptions: Partial<ListResourceOptions>) => void;
fetch: () => void;
reload: () => Promise<T[]>;
@@ -401,7 +402,6 @@ export interface View {
group_by_field?: string;
name?: string;
is_customer_portal?: boolean;
is_standard?: boolean;
}
export interface ViewType {
@@ -736,6 +736,10 @@ export const SavedReplyListResourceSymbol: InjectionKey<
ListResource<SavedReply>
> = Symbol("savedReplyListResource");
export const CustomerResourceSymbol: InjectionKey<
DocumentResource<HDCustomer>
> = Symbol("customerResource");
declare global {
interface Window {
is_fc_site: boolean;
@@ -744,6 +748,58 @@ declare global {
session_user: string;
timezone: Record<"user" | "system", string>;
agent: string | null;
default_country: string;
apps: string[];
}
}
export interface CustomerContact {
contact_name: string;
is_primary: 0 | 1;
is_manager: 0 | 1;
email_id: string | null;
mobile_no: string | null;
image: string | null;
last_active: string;
modified?: string;
ticket_count: number;
}
export interface NewContactState {
firstName: string;
lastName: string;
image: string;
email: string;
phone: string;
timezone: string;
customer: string;
invite: boolean;
}
export interface ContactEmailEntry {
email_id: string;
isPrimary: boolean;
key: number;
}
export interface ContactPhoneEntry {
phone: string;
isPrimary: boolean;
key: number;
}
export interface EditContactState {
firstName: string;
lastName: string;
image: string;
emails: ContactEmailEntry[];
phones: ContactPhoneEntry[];
customers: string[];
customer: string;
timezone: string;
}
export interface LineChart {
percentage_change: number;
total: number;
data: { date: string; count: number }[];
}
+173 -9
View File
@@ -13,10 +13,23 @@ interface DocType {
idx?: number;
}
// Last updated: 2026-02-02 17:22:11.094897
// Last updated: 2026-03-24 10:41:46.489051
export interface HDTicketStatus extends DocType {
/** Color: Select */
color?: 'Black' | 'Gray' | 'Blue' | 'Green' | 'Red' | 'Pink' | 'Orange' | 'Amber' | 'Yellow' | 'Cyan' | 'Teal' | 'Violet' | 'purple';
color?:
| "Black"
| "Gray"
| "Blue"
| "Green"
| "Red"
| "Pink"
| "Orange"
| "Amber"
| "Yellow"
| "Cyan"
| "Teal"
| "Violet"
| "purple";
/** Label: Data */
label_agent: string;
/** Show end users a different view: Check */
@@ -24,12 +37,11 @@ export interface HDTicketStatus extends DocType {
/** Label (customer view): Data */
label_customer?: string;
/** Category: Select */
category: 'Open' | 'Paused' | 'Resolved';
category: "Open" | "Paused" | "Resolved";
/** Order: Int */
order?: number;
/** Enabled: Check */
enabled: 0 | 1;
parsed_color?: string;
}
// Last updated: 2026-05-11 18:25:54.352721
@@ -57,7 +69,13 @@ export interface HDTicket extends DocType {
/** Response By: Datetime */
response_by?: string;
/** SLA Status: Select */
agreement_status?: '' | 'First Response Due' | 'Resolution Due' | 'Failed' | 'Fulfilled' | 'Paused';
agreement_status?:
| ""
| "First Response Due"
| "Resolution Due"
| "Failed"
| "Fulfilled"
| "Paused";
/** Resolution By: Datetime */
resolution_by?: string;
/** SLA Creation: Datetime */
@@ -122,6 +140,8 @@ export interface HDTicket extends DocType {
first_response_failed_by?: number;
/** Resolution Failed By: Duration */
resolution_failed_by?: number;
/** Assignees: JSON */
_assign: string;
}
// Last updated: 2026-03-03 12:30:01.394107
@@ -130,12 +150,21 @@ export interface AssignmentRuleUser extends ChildDocType {
user: string;
/** Weight: Int */
weight?: number;
/** Weight: Int */
weight?: number;
}
// Last updated: 2024-03-23 16:01:27.759155
export interface AssignmentRuleDay extends ChildDocType {
/** Day: Select */
day?: 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday' | 'Sunday';
day?:
| "Monday"
| "Tuesday"
| "Wednesday"
| "Thursday"
| "Friday"
| "Saturday"
| "Sunday";
}
// Last updated: 2026-03-03 13:08:12.561504
@@ -153,7 +182,11 @@ export interface AssignmentRule extends DocType {
/** Unassign Condition: Code */
unassign_condition?: string;
/** Rule: Select */
rule: 'Round Robin' | 'Load Balancing' | 'Based on Field' | 'Weighted Distribution';
rule:
| "Round Robin"
| "Load Balancing"
| "Based on Field"
| "Weighted Distribution";
/** Users: Table MultiSelect (Assignment Rule User) */
users: AssignmentRuleUser[];
/** Last User: Link (User) */
@@ -193,7 +226,15 @@ export interface HDServiceHolidayList extends DocType {
/** Total Holidays: Int */
total_holidays?: number;
/** Weekly Off: Select */
weekly_off?: '' | 'Sunday' | 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday';
weekly_off?:
| ""
| "Sunday"
| "Monday"
| "Tuesday"
| "Wednesday"
| "Thursday"
| "Friday"
| "Saturday";
/** Holidays: Table (HD Holiday) */
holidays: HDHoliday[];
/** Color: Color */
@@ -207,7 +248,14 @@ export interface HDServiceHolidayList extends DocType {
// Last updated: 2021-10-21 14:27:08.190239
export interface HDServiceDay extends ChildDocType {
/** Workday: Select */
workday: 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday' | 'Sunday';
workday:
| "Monday"
| "Tuesday"
| "Wednesday"
| "Thursday"
| "Friday"
| "Saturday"
| "Sunday";
/** Start Time: Time */
start_time: any;
/** End Time: Time */
@@ -309,3 +357,119 @@ export interface HDTeam extends DocType {
/** Disabled: Check */
disabled: 0 | 1;
}
// Last updated: 2026-02-20 17:04:23.230132
export interface HDCustomerMember extends ChildDocType {
/** Is Manager: Check */
is_manager: 0 | 1;
/** Name: Link (Contact) */
contact_name: string;
}
// Last updated: 2026-06-20 15:34:00.998330
export interface HDCustomer extends DocType {
/** Image: Attach Image */
image?: string;
/** Customer Name: Data */
customer_name?: string;
/** Customer Type: Select */
customer_type?: 'Company' | 'Individual' | 'Partnership';
/** Domain: Data */
domain?: string;
/** ERPNext Customer: Data */
erpnext_customer?: string;
/** Contacts: Table (HD Customer Member) */
contacts: HDCustomerMember[];
/** Mobile No: Data */
mobile_no?: string;
/** Email ID: Data */
email_id?: string;
/** Primary Contact: Link (Contact) */
primary_contact?: string;
/** Country: Link (Country) */
country?: string;
}
// Last updated: 2024-03-23 16:03:23.469424
export interface DynamicLink extends ChildDocType {
/** Link Document Type: Link (DocType) */
link_doctype: string;
/** Link Name: Dynamic Link (link_doctype) */
link_name: string;
/** Link Title: Read Only */
link_title?: string;
}
// Last updated: 2024-03-23 16:01:31.173691
export interface ContactEmail extends ChildDocType {
/** Email ID: Data */
email_id: string;
/** Is Primary: Check */
is_primary: 0 | 1;
}
// Last updated: 2024-03-23 16:01:31.293047
export interface ContactPhone extends ChildDocType {
/** Number: Data */
phone: string;
/** Is Primary Phone: Check */
is_primary_phone: 0 | 1;
/** Is Primary Mobile: Check */
is_primary_mobile_no: 0 | 1;
}
// Last updated: 2024-03-23 16:01:30.937045
export interface Contact extends DocType {
/** First Name: Data */
first_name?: string;
/** Last Name: Data */
last_name?: string;
/** Email Address: Data */
email_id?: string;
/** User Id: Link (User) */
user?: string;
/** Status: Select */
status?: "Passive" | "Open" | "Replied";
/** Salutation: Link (Salutation) */
salutation?: string;
/** Gender: Link (Gender) */
gender?: string;
/** Phone: Data */
phone?: string;
/** Image: Attach Image */
image?: string;
/** Is Primary Contact: Check */
is_primary_contact: 0 | 1;
/** Links: Table (Dynamic Link) */
links: DynamicLink[];
/** Department: Data */
department?: string;
/** Designation: Data */
designation?: string;
/** Unsubscribed: Check */
unsubscribed: 0 | 1;
/** Middle Name: Data */
middle_name?: string;
/** Email IDs: Table (Contact Email) */
email_ids: ContactEmail[];
/** Address: Link (Address) */
address?: string;
/** Contact Numbers: Table (Contact Phone) */
phone_nos: ContactPhone[];
/** Mobile No: Data */
mobile_no?: string;
/** Pulled from Google Contacts: Check */
pulled_from_google_contacts: 0 | 1;
/** Sync with Google Contacts: Check */
sync_with_google_contacts: 0 | 1;
/** Google Contacts: Link (Google Contacts) */
google_contacts?: string;
/** Google Contacts Id: Data */
google_contacts_id?: string;
/** Company Name: Data */
company_name?: string;
/** Full Name: Data */
full_name?: string;
/** Timezone: Data (from linked User) */
timezone?: string;
}
+75 -28
View File
@@ -1,3 +1,4 @@
import { useAuthStore } from "@/stores/auth";
import type { DropdownOption } from "@/types";
import { useClipboard } from "@vueuse/core";
import {
@@ -215,6 +216,7 @@ export function formatTime(
hour?: boolean;
minute?: boolean;
second?: boolean;
maxUnits?: number;
} = {
day: true,
hour: true,
@@ -227,31 +229,34 @@ export function formatTime(
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
let formattedTime = "";
const parts: string[] = [];
if (config.day && days > 0) {
formattedTime += `${days}d `;
parts.push(`${days}d`);
}
if (config.hour && (hours > 0 || days > 0)) {
formattedTime += `${hours}h `;
parts.push(`${hours}h`);
}
if (config.minute && (minutes > 0 || hours > 0 || days > 0)) {
formattedTime += `${minutes}m `;
parts.push(`${minutes}m`);
}
if (config.second) {
formattedTime += `${
remainingSeconds >= 10
? remainingSeconds
: remainingSeconds > 1
? "0" + remainingSeconds
: "0"
}s`;
parts.push(
`${
remainingSeconds >= 10
? remainingSeconds
: remainingSeconds > 1
? "0" + remainingSeconds
: "0"
}s`
);
}
return formattedTime.trim();
const limited = config.maxUnits ? parts.slice(0, config.maxUnits) : parts;
return limited.join(" ").trim();
}
export function getTimeInSeconds(time: string) {
@@ -783,7 +788,7 @@ export function parseApiOptions(
}
export function openContact(name: string) {
const url = window.location.origin + "/app/contact/" + name;
const url = window.location.origin + "/helpdesk/contacts/" + name;
window.open(url, "_blank");
}
@@ -876,23 +881,65 @@ export function shortDuration(target: string | Date): string {
return `${Math.floor(seconds / MINUTE)}m`;
}
export function buildPercentageChange(value: number | null) {
if (value === null || value === undefined) {
export function buildPercentageChange(
value: number | null,
negativeIsBetter: boolean = true
) {
// No change (or no comparison): stay neutral — never green/red, no up/down arrow.
if (value === null || value === undefined || value === 0) {
return { icon: "lucide-arrow-right", value: "0", color: "text-ink-gray-5" };
}
const isPositive = value > 0;
const isGood = negativeIsBetter ? !isPositive : isPositive;
return {
icon:
value > 0
? "lucide-arrow-up-right"
: value < 0
? "lucide-arrow-down-left"
: "lucide-arrow-right",
value: value > 0 ? `+${value}` : value,
color:
value > 0
? "text-ink-red-4"
: value < 0
? "text-ink-green-3"
: "text-ink-gray-5",
icon: isPositive ? "lucide-arrow-up-right" : "lucide-arrow-down-left",
value: isPositive ? `+${value}` : value,
color: isGood ? "text-ink-green-3" : "text-ink-red-3",
};
}
export function hasPermission() {
const authStore = useAuthStore();
return authStore.isAdmin || authStore.isManager;
}
export function getErrorMessage(
error: any,
showToast: boolean = false
): string {
const msg = error.exc_type
? (error.messages || error.message || []).join(", ")
: error.message;
if (showToast) {
toast.error(msg);
}
return msg;
}
const emailsToStr = (emails: readonly string[]) => emails.join(", ");
export function handleInviteUserSuccess(
data: Record<
| "disabled_user_emails"
| "accepted_invite_emails"
| "pending_invite_emails"
| "invited_emails",
string[]
>
) {
let emailsStr = emailsToStr(data.invited_emails);
if (emailsStr.trim() !== "") {
toast.success(`${emailsStr} invited successfully`);
}
emailsStr = emailsToStr(data.disabled_user_emails);
if (emailsStr.trim() !== "") {
toast.info(`${emailsStr} already present and disabled`);
}
emailsStr = emailsToStr(data.pending_invite_emails);
if (emailsStr.trim() !== "") {
toast.info(`${emailsStr} already invited`);
}
emailsStr = emailsToStr(data.accepted_invite_emails);
if (emailsStr.trim() !== "") {
toast.info(`${emailsStr} already present`);
}
}
+2 -1
View File
@@ -35,8 +35,9 @@ export default defineConfig(async ({ mode }) => {
"hd_service_level_agreement",
"hd_agent",
"hd_team",
"hd_customer",
],
frappe: ["assignment_rule"],
frappe: ["assignment_rule", "contact"],
},
},
}),
+28 -119
View File
@@ -13,6 +13,7 @@ from helpdesk.api.agent_home.utils import (
get_default_agent_dashboard,
get_ticket_count,
)
from helpdesk.api.analytics_utils import get_avg_time_metric
from helpdesk.utils import agent_only, format_time_difference
@@ -53,6 +54,29 @@ def get_dashboard(reset_layout: bool = False):
}
def _resolve_window(period: str):
"""Returns (current_from, current_to, prev_from, prev_to) using a rolling
window via date_diff (matches dashboard.py)."""
periods = {
"last 7 days": 7,
"last week": 7,
"last month": 30,
"last 3 months": 90,
}
days = periods.get((period or "").lower(), 30)
current_to = frappe.utils.nowdate()
current_from = frappe.utils.add_days(current_to, -(days - 1))
diff = frappe.utils.date_diff(current_to, current_from)
if diff == 0:
diff = 1
previous_from = frappe.utils.add_days(current_from, -diff)
previous_to = frappe.utils.add_days(current_from, -1)
return current_from, current_to, previous_from, previous_to
@frappe.whitelist()
@agent_only
def get_agent_tickets(period: str = "last month"):
@@ -114,133 +138,16 @@ def get_agent_tickets(period: str = "last month"):
}
def get_avg_time(from_date, to_date, value_expr, extra_cond, group_by_date=False):
Ticket = DocType("HD Ticket")
to_date_plus_one = Function(
"DATE_ADD", to_date, frappe.qb.terms.PseudoColumn("INTERVAL 1 DAY")
)
query = frappe.qb.from_(Ticket)
if group_by_date:
creation_date = Function("DATE", Ticket.creation)
query = (
query.select(
creation_date.as_("date"),
Avg(value_expr).as_("avg_time"),
)
.groupby(creation_date)
.orderby(creation_date)
)
else:
query = query.select(Avg(value_expr).as_("avg_time"))
query = (
query.where(Ticket.creation >= from_date)
.where(Ticket.creation < to_date_plus_one)
.where(
Function(
"JSON_SEARCH", Ticket._assign, "one", frappe.session.user
).isnotnull()
)
)
if extra_cond is not None:
query = query.where(extra_cond)
result = query.run(as_dict=True)
if group_by_date:
return result
return result[0]["avg_time"] if result and result[0]["avg_time"] is not None else 0
def _resolve_window(period: str):
"""Returns (current_from, current_to, prev_from, prev_to) using a rolling
window via date_diff (matches dashboard.py)."""
periods = {
"last 7 days": 7,
"last week": 7,
"last month": 30,
"last 3 months": 90,
}
days = periods.get((period or "").lower(), 30)
current_to = frappe.utils.nowdate()
current_from = frappe.utils.add_days(current_to, -(days - 1))
diff = frappe.utils.date_diff(current_to, current_from)
if diff == 0:
diff = 1
previous_from = frappe.utils.add_days(current_from, -diff)
previous_to = frappe.utils.add_days(current_from, -1)
return current_from, current_to, previous_from, previous_to
def _get_avg_time_metric(period: str, value_expr, extra_cond) -> dict:
current_from, current_to, previous_from, previous_to = _resolve_window(period)
current_result = get_avg_time(
current_from, current_to, value_expr, extra_cond, group_by_date=True
)
current_avg = get_avg_time(current_from, current_to, value_expr, extra_cond)
previous_avg = get_avg_time(previous_from, previous_to, value_expr, extra_cond)
percentage_change = calculate_percentage_change(current_avg, previous_avg)
# Fill missing days with 0
from_date_obj = date.fromisoformat(current_from)
to_date_obj = date.fromisoformat(current_to)
date_dict = {}
current_date = from_date_obj
while current_date <= to_date_obj:
date_str = current_date.isoformat()
date_dict[date_str] = 0
current_date += timedelta(days=1)
for row in current_result:
date_dict[str(row["date"])] = round(row["avg_time"] or 0, 2)
data = [
{"date": date, "avg_time": avg_time}
for date, avg_time in sorted(date_dict.items())
]
return {
"data": data,
"average": round(current_avg, 2),
"percentage_change": percentage_change,
}
@frappe.whitelist()
@agent_only
def get_avg_first_response_time(period: str = "last month"):
Ticket = DocType("HD Ticket")
return _get_avg_time_metric(
period,
value_expr=Ticket.first_response_time,
extra_cond=Ticket.first_responded_on.isnotnull(),
)
return get_avg_time_metric(period, "first_response_time", scope="agent")
@frappe.whitelist()
@agent_only
def get_avg_resolution_time(period: str = "last month"):
Ticket = DocType("HD Ticket")
resolved_statuses = frappe.get_all(
"HD Ticket Status",
filters={"category": "Resolved"},
pluck="name",
)
extra_cond = Ticket.status.isin(resolved_statuses) if resolved_statuses else None
return _get_avg_time_metric(
period,
value_expr=Ticket.resolution_time,
extra_cond=extra_cond,
)
return get_avg_time_metric(period, "resolution_time", scope="agent")
@frappe.whitelist()
@@ -267,6 +174,8 @@ def get_recent_feedback(
period_filter = frappe.utils.add_days(frappe.utils.nowdate(), -30)
elif period == "last_3_months":
period_filter = frappe.utils.add_days(frappe.utils.nowdate(), -90)
elif period == "last_6_months":
period_filter = frappe.utils.add_days(frappe.utils.nowdate(), -180)
# Base query conditions
base_conditions = [
@@ -497,6 +497,13 @@ class TestAgentHome(FrappeTestCase):
# Other agent's ticket should NOT be in the result
self.assertNotIn(other_ticket.name, ticket_names)
# delete the SLA to clean up
frappe.set_user("Administrator")
tickets = [ticket, other_ticket]
for t in tickets:
frappe.delete_doc("HD Ticket", t.name, force=True)
frappe.delete_doc("HD Service Level Agreement", t.sla, force=True)
def test_get_pending_tickets_new_tickets_type(self):
"""Test getting newly assigned tickets"""
# Create a second agent for non-matching tickets
+4 -3
View File
@@ -7,11 +7,12 @@ 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}}]'
def calculate_percentage_change(current_value: float, previous_value: float) -> float:
def calculate_percentage_change(
current_value: float, previous_value: float
) -> float | None:
"""
Calculate the percentage change between two values.
Returns 999 when there's no previous value but there is a current value.
Returns 0 when both values are zero.
Returns 0 when both values are zero. None when the previous value is zero and the current value is greater than zero.
"""
if previous_value > 0:
return round(((current_value - previous_value) / previous_value) * 100, 2)
+175
View File
@@ -0,0 +1,175 @@
from datetime import date, timedelta
import frappe
from frappe.query_builder import DocType
from frappe.query_builder.functions import Avg, Function
from frappe.utils import add_days, nowdate
from helpdesk.api.agent_home.utils import calculate_percentage_change
Scope = str # Literal["agent", "customer", "contact"]
def _apply_scope(query, Ticket, scope: Scope, identifier: str | None):
"""
Append the appropriate WHERE clause for the given scope.
Parameters
----------
query : PyPika query being built
Ticket : DocType("HD Ticket") table reference
scope : "agent" | "customer" | "contact"
identifier : agent email / customer name / contact name
"""
if scope == "agent":
query = query.where(
Function(
"JSON_SEARCH", Ticket._assign, "one", identifier or frappe.session.user
).isnotnull()
)
elif scope == "customer":
query = query.where(Ticket.customer == identifier)
elif scope == "contact":
query = query.where(Ticket.contact == identifier)
else:
frappe.throw(
f"Invalid scope '{scope}'. Must be 'agent', 'customer', or 'contact'."
)
return query
def get_avg_time(
from_date,
to_date,
time_field: str,
scope: Scope = "agent",
identifier: str | None = None,
group_by_date: bool = False,
) -> list:
"""
Return the average value of *time_field* on HD Ticket rows that fall in
[from_date, to_date] and match the given scope/identifier.
When group_by_date=True returns a list of {date, avg_time} dicts.
When group_by_date=False returns a single-element list [{avg_time: ...}].
"""
if identifier is None:
identifier = frappe.session.user
Ticket = DocType("HD Ticket")
to_date_plus_one = Function(
"DATE_ADD", to_date, frappe.qb.terms.PseudoColumn("INTERVAL 1 DAY")
)
query = frappe.qb.from_(Ticket)
if group_by_date:
creation_date = Function("DATE", Ticket.creation)
query = (
query.select(
creation_date.as_("date"),
Avg(Ticket[time_field]).as_("avg_time"),
)
.groupby(creation_date)
.orderby(creation_date)
)
else:
query = query.select(Avg(Ticket[time_field]).as_("avg_time"))
query = (
query.where(Ticket.creation >= from_date)
.where(Ticket.creation < to_date_plus_one)
.where(Ticket[time_field].isnotnull())
)
if time_field == "resolution_time":
query = _restrict_to_resolved(query, Ticket)
query = _apply_scope(query, Ticket, scope, identifier)
return query.run(as_dict=True)
def _restrict_to_resolved(query, Ticket):
"""Average resolution time should only count tickets in a Resolved-category
status, since `resolution_time` can linger on reopened tickets."""
resolved_statuses = frappe.get_all(
"HD Ticket Status", filters={"category": "Resolved"}, pluck="name"
)
if resolved_statuses:
query = query.where(Ticket.status.isin(resolved_statuses))
return query
def _scalar_avg_time(
from_date,
to_date,
time_field: str,
scope: Scope,
identifier: str,
) -> float:
"""Return a single average float (no date grouping)."""
result = get_avg_time(
from_date, to_date, time_field, scope, identifier, group_by_date=False
)
return (
result[0]["avg_time"] if result and result[0]["avg_time"] is not None else 0.0
)
def get_avg_time_metric(
period: str,
time_field: str,
scope: Scope = "agent",
identifier: str | None = None,
) -> dict:
"""
Build the avg-time metric dict (data series + average + % change) for a
given period, time field, and scope.
"""
resolved: str = identifier or str(frappe.session.user)
periods = {
"last week": 7,
"last month": 30,
"last 3 months": 90,
"last 6 months": 180,
}
days = periods.get(period, 7)
current_from = add_days(nowdate(), -(days - 1))
current_to = nowdate()
previous_from = add_days(nowdate(), -(2 * days - 1))
previous_to = add_days(nowdate(), -days)
current_series = get_avg_time(
current_from, current_to, time_field, scope, resolved, group_by_date=True
)
current_avg = _scalar_avg_time(
current_from, current_to, time_field, scope, resolved
)
previous_avg = _scalar_avg_time(
previous_from, previous_to, time_field, scope, resolved
)
percentage_change = calculate_percentage_change(current_avg, previous_avg)
# Fill missing days with 0
from_date_obj = date.fromisoformat(str(current_from))
to_date_obj = date.fromisoformat(str(current_to))
date_dict: dict[str, float] = {}
cur = from_date_obj
while cur <= to_date_obj:
date_dict[cur.isoformat()] = 0
cur += timedelta(days=1)
for row in current_series:
date_dict[str(row["date"])] = round(row["avg_time"] or 0, 2)
data = [
{"date": d, "avg_time": avg_time} for d, avg_time in sorted(date_dict.items())
]
return {
"data": data,
"average": round(current_avg, 2),
"percentage_change": percentage_change,
}
+183 -2
View File
@@ -1,13 +1,23 @@
from typing import Literal
import frappe
from frappe.core.api.user_invitation import invite_by_email
from frappe.desk.reportview import delete_bulk
from frappe.utils import validate_phone_number_with_country_code
from helpdesk.utils import get_country_from_timezone, get_customers
@frappe.whitelist(methods=["GET"])
def search_contacts(
txt: str,
txt: str, additional_filters: str | list | None = None
) -> list[dict[Literal["full_name", "name", "email_id"], str]]:
doctype = "Contact"
filters: list[list] = [[doctype, "email_id", "is", "set"]]
if additional_filters:
if isinstance(additional_filters, str):
additional_filters = frappe.parse_json(additional_filters)
filters.extend(additional_filters)
or_filters: list[list[str]] = []
search_fields = ["full_name", "email_id", "name"]
if txt:
@@ -15,7 +25,7 @@ def search_contacts(
or_filters.append([doctype, field, "like", f"%{txt}%"])
return frappe.get_list(
doctype,
filters=[[doctype, "email_id", "is", "set"]],
filters=filters,
fields=search_fields,
or_filters=or_filters,
limit_start=0,
@@ -24,3 +34,174 @@ def search_contacts(
ignore_permissions=False,
strict=False,
)
@frappe.whitelist()
def delete_contact(name: str, delete_tickets: bool = False):
frappe.has_permission("Contact", "delete", throw=True)
tickets = frappe.get_list("HD Ticket", filters={"contact": name}, pluck="name")
frappe.has_permission("HD Ticket", "write", throw=True)
if len(tickets) > 0:
frappe.db.set_value("HD Ticket", tickets, "contact", None)
if delete_tickets:
frappe.has_permission("HD Ticket", "delete", throw=True)
delete_bulk("HD Ticket", tickets)
unlink_from_customers(name)
frappe.db.set_value(
"User Invitation",
{"contact": name, "app_name": "helpdesk"},
"contact",
None,
)
frappe.delete_doc("Contact", name)
def unlink_from_customers(name: str):
customers = frappe.get_all(
"HD Customer Member",
filters={"contact_name": name},
pluck="parent",
)
for customer in customers:
customer_doc = frappe.get_doc("HD Customer", customer)
customer_doc.remove_contact(name)
customer_doc.save()
@frappe.whitelist()
def create_contact(doc: dict, invite: bool = False) -> str:
frappe.has_permission("Contact", "create", throw=True)
phone = doc.get("phone")
if phone:
validate_phone_number_with_country_code(phone, "Phone")
contact_doc = frappe.get_doc(
{
"doctype": "Contact",
"first_name": doc.get("first_name"),
"last_name": doc.get("last_name"),
"image": doc.get("image"),
}
)
if email := doc.get("email"):
contact_doc.append("email_ids", {"email_id": email, "is_primary": True})
if phone:
contact_doc.append(
"phone_nos",
{"phone": phone, "is_primary_phone": True, "is_primary_mobile_no": True},
)
contact_doc.insert()
if invite:
invite_by_email(
email,
["HD Customer"],
redirect_to_path="/helpdesk",
app_name="helpdesk",
contact=contact_doc.name,
customer=doc.get("customer"),
)
contact_doc.reload()
return contact_doc.get("name")
@frappe.whitelist(methods=["POST"])
def edit_contact(name: str, doc: dict):
frappe.has_permission("Contact", "write", throw=True)
contact_doc = frappe.get_doc("Contact", name)
contact_doc.first_name = doc.get("first_name", contact_doc.first_name)
contact_doc.last_name = doc.get("last_name", contact_doc.last_name)
contact_doc.image = doc.get("image", contact_doc.image)
email_ids = doc.get("email_ids")
if email_ids is not None:
contact_doc.email_ids = []
for e in email_ids:
contact_doc.append(
"email_ids",
{"email_id": e.get("email_id"), "is_primary": e.get("is_primary")},
)
phone_nos = doc.get("phone_nos")
if phone_nos is not None:
for index, p in enumerate(phone_nos, start=1):
if phone := p.get("phone"):
validate_phone_number_with_country_code(phone, f"Phone-row {index}")
contact_doc.phone_nos = []
for p in phone_nos:
contact_doc.append(
"phone_nos",
{
"phone": p.get("phone"),
"is_primary_phone": p.get("is_primary"),
"is_primary_mobile_no": p.get("is_primary"),
},
)
contact_doc.save()
if user := contact_doc.get("user"):
user_doc = frappe.get_doc("User", user)
user_doc.user_image = doc.get("image", "")
user_doc.time_zone = doc.get("timezone", "")
user_doc.save()
return contact_doc.name
@frappe.whitelist(methods=["GET"])
def get_contact_info(name: str) -> dict:
frappe.has_permission("Contact", "read", doc=name, throw=True)
contact = frappe.get_doc("Contact", name)
primary_email = contact.email_ids[0].email_id if contact.email_ids else None
result = {
"customers": get_customers_with_image(name),
"last_seen": None,
}
if not contact.user and (invite := get_invitation(name, primary_email)):
result["invitation"] = invite
if contact.user:
user = frappe.db.get_value(
"User", contact.user, ["time_zone", "last_active"], as_dict=True
)
result["last_seen"] = user.last_active
if user.time_zone:
result["timezone"] = user.time_zone
result["country"] = get_country_from_timezone(user.time_zone)
return result
def get_customers_with_image(name: str) -> list[dict]:
customers = get_customers(contact=name)
return [
{"name": c, "image": frappe.db.get_value("HD Customer", c, "image")}
for c in customers
]
def get_invitation(name: str, email: str | None) -> dict | None:
if not email:
return None
return frappe.db.get_value(
"User Invitation",
{
"contact": name,
"status": ["!=", "Accepted"],
"email": email,
"app_name": "helpdesk",
},
["name", "status"],
as_dict=True,
order_by="creation desc",
)
+60
View File
@@ -0,0 +1,60 @@
import frappe
from frappe.model.document import Document
from helpdesk.api.contact import create_contact
@frappe.whitelist()
def create_customer(customer: dict, primary_contact: dict | None = None) -> str:
"""Create an HD Customer and optionally invite a primary contact.
The primary contact is created, invited by email and set as the customer's
primary contact in a single operation.
"""
frappe.has_permission("HD Customer", "create", throw=True)
customer_doc = frappe.get_doc(
{
"doctype": "HD Customer",
"customer_name": customer.get("customer_name"),
"customer_type": customer.get("customer_type"),
"domain": customer.get("domain"),
"image": customer.get("image"),
"country": customer.get("country"),
}
)
customer_doc.insert()
if primary_contact and primary_contact.get("email"):
add_primary_contact(customer_doc, primary_contact)
return customer_doc.name
def add_primary_contact(customer_doc: Document, primary_contact: dict) -> None:
contact_name = create_contact(
{
"first_name": primary_contact.get("first_name"),
"last_name": primary_contact.get("last_name"),
"email": primary_contact.get("email"),
"phone": primary_contact.get("mobile_no"),
"customer": customer_doc.name,
},
invite=True,
)
customer_doc.set_primary(contact_name)
customer_doc.save()
@frappe.whitelist()
def delete_customer(name: str, delete_tickets: bool = False) -> None:
"""Delete an HD Customer, either deleting or unlinking its tickets.
When ``delete_tickets`` is set, every ticket linked to the customer is
deleted as well. Otherwise the ``customer`` link on those tickets is cleared
so the tickets are preserved but no longer tied to the deleted customer.
"""
frappe.has_permission("HD Customer", "delete", throw=True)
permission = "delete" if delete_tickets else "write"
frappe.has_permission("HD Ticket", permission, throw=True)
frappe.delete_doc("HD Customer", name, flags={"delete_tickets": delete_tickets})
+4 -1
View File
@@ -11,6 +11,7 @@ from helpdesk.utils import (
call_log_default_columns,
check_permissions,
contact_default_columns,
contact_default_rows,
parse_call_logs,
)
@@ -76,6 +77,7 @@ def get_list_data(
if not default_view:
if doctype == "Contact":
columns = contact_default_columns
rows = contact_default_rows
elif doctype == "TP Call Log":
columns = call_log_default_columns
elif hasattr(_list, "default_list_data"):
@@ -272,6 +274,7 @@ def get_filterable_fields(
"response_by",
"resolution_by",
"creation",
"customer",
]
from_doc_fields = (
@@ -501,7 +504,7 @@ def handle_default_view(doctype, _list, show_customer_portal_fields):
if not columns:
if doctype == "Contact":
columns = contact_default_columns
rows = ["name", "email_id", "creation"]
rows = ["name", "email_id", "mobile_no", "image", "creation"]
elif doctype == "TP Call Log":
columns = call_log_default_columns
rows = ["name", "caller", "receiver", "creation"]
+7 -1
View File
@@ -1,6 +1,12 @@
import frappe
ALLOWED_APP_ROLES = {"Agent", "Agent Manager", "System Manager"}
ALLOWED_APP_ROLES = {
"Agent",
"Agent Manager",
"System Manager",
"HD Customer",
"HD Customer Manager",
}
def has_app_permission():
+84
View File
@@ -0,0 +1,84 @@
# Copyright (c) 2022, Frappe Technologies and Contributors
# See license.txt
import frappe
from frappe.tests import IntegrationTestCase
from helpdesk.api.contact import delete_contact
from helpdesk.test_utils import create_contact, create_customer, make_ticket
class TestDeleteContact(IntegrationTestCase):
def setUp(self) -> None:
frappe.set_user("Administrator")
def tearDown(self) -> None:
frappe.set_user("Administrator")
def test_delete_contact_unlinks_tickets_and_invitations(self) -> None:
email = "[email protected]"
contact, customer, ticket, invitation = self.setup_contact_for_delete(
"Test Contact Unlink", email
)
delete_contact(contact["contact"])
self.assertFalse(frappe.db.exists("Contact", contact["contact"]))
ticket.reload()
self.assertIsNone(ticket.contact)
invitation.reload()
self.assertIsNone(invitation.contact)
self.assertFalse(
frappe.db.exists(
"HD Customer Member",
{"parent": customer.name, "contact_name": contact["contact"]},
)
)
def test_delete_contact_deletes_linked_tickets(self) -> None:
email = "[email protected]"
contact, _, ticket, invitation = self.setup_contact_for_delete(
"Test Contact Delete Tickets", email
)
delete_contact(contact["contact"], delete_tickets=True)
self.assertFalse(frappe.db.exists("Contact", contact["contact"]))
self.assertFalse(frappe.db.exists("HD Ticket", ticket.name))
invitation.reload()
self.assertIsNone(invitation.contact)
def test_delete_contact_clears_customer_primary_contact(self) -> None:
email = "[email protected]"
contact_doc, customer, _, _ = self.setup_contact_for_delete(
"Test Contact Primary", email
)
customer.primary_contact = contact_doc["contact"]
customer.save()
delete_contact(contact_doc["contact"])
customer.reload()
self.assertIsNone(customer.primary_contact)
def setup_contact_for_delete(self, customer_name: str, email: str):
for inv in frappe.db.get_all("User Invitation", {"email": email}, pluck="name"):
frappe.delete_doc("User Invitation", inv, force=True)
contact = create_contact("DeleteContact", email, user=False)
customer = create_customer(
customer_name, [{"contact_name": contact["contact"]}]
)
ticket = make_ticket(customer=customer.name, contact=contact["contact"])
invitation = frappe.get_doc(
{
"doctype": "User Invitation",
"email": email,
"app_name": "helpdesk",
"redirect_to_path": "/helpdesk",
"roles": [{"role": "HD Customer"}],
"customer": customer.name,
"contact": contact["contact"],
}
).insert(ignore_permissions=True)
return contact, customer, ticket, invitation
+54
View File
@@ -0,0 +1,54 @@
import frappe
from frappe.tests import IntegrationTestCase
from helpdesk.api.ticket_stats import get_feedback_received, get_ticket_stats
from helpdesk.test_utils import create_agent, create_contact, create_customer
class TestTicketStatsPermissions(IntegrationTestCase):
agent_a = "[email protected]"
agent_b = "[email protected]"
manager = "[email protected]"
def setUp(self) -> None:
create_agent(self.agent_a)
create_agent(self.agent_b)
create_agent(self.manager)
frappe.get_doc("User", self.manager).add_roles("Agent Manager")
frappe.set_user("Administrator")
def tearDown(self) -> None:
frappe.set_user("Administrator")
def test_agent_cannot_view_another_agents_stats(self) -> None:
# The core leak: a plain agent reading a peer's numbers by passing their
# email as the agent-scope value.
frappe.set_user(self.agent_a)
with self.assertRaises(frappe.PermissionError):
get_feedback_received(scope="agent", value=self.agent_b)
def test_agent_can_view_own_stats(self) -> None:
frappe.set_user(self.agent_a)
self.assertIn("data", get_feedback_received(scope="agent", value=self.agent_a))
def test_agent_manager_can_view_any_agent_stats(self) -> None:
frappe.set_user(self.manager)
self.assertIn("data", get_feedback_received(scope="agent", value=self.agent_a))
def test_agent_can_view_customer_and_contact_stats(self) -> None:
# Customer/contact scope stays open to agents (they already see those
# records), so the analytics pages keep working.
customer = create_customer("Stats Customer")
contact = create_contact("StatsContact", "[email protected]")
frappe.set_user(self.agent_a)
self.assertIn(
"feedback_received", get_ticket_stats("HD Customer", customer.name)
)
self.assertIn(
"data", get_feedback_received(scope="contact", value=contact["contact"])
)
def test_invalid_scope_is_rejected(self) -> None:
frappe.set_user(self.agent_a)
with self.assertRaises(frappe.ValidationError):
get_feedback_received(scope="team", value="anything")
+248
View File
@@ -0,0 +1,248 @@
from datetime import date, timedelta
import frappe
from frappe import _
from frappe.query_builder import DocType
from frappe.query_builder.functions import Count, Function
from frappe.utils import add_days, nowdate
from helpdesk.api.agent_home.utils import calculate_percentage_change
from helpdesk.api.analytics_utils import _apply_scope, get_avg_time_metric
from helpdesk.utils import agent_only
Scope = str # Literal["agent", "customer", "contact"]
DT_FIELD_MAP = {
"HD Customer": "customer",
"Contact": "contact",
}
periods = {"last week": 7, "last month": 30, "last 3 months": 90, "last 6 months": 180}
def _resolve_scope(dt: str) -> str:
if dt not in DT_FIELD_MAP:
frappe.throw(_("Unsupported doctype '{0}' for ticket stats.").format(dt))
return DT_FIELD_MAP[dt]
def validate_agent_access(scope: Scope, value: str) -> None:
"""Only the agent themselves or an Agent Manager may read an agent's stats."""
viewing_other_agent = scope == "agent" and value and value != frappe.session.user
if viewing_other_agent and "Agent Manager" not in frappe.get_roles():
frappe.throw(
_("You are not allowed to view another agent's stats."),
frappe.PermissionError,
)
def _fill_date_series(from_date_str, to_date_str, rows: list) -> list:
"""Return a {date, count} list for every day in the range, filling 0 for missing days."""
date_dict: dict[str, int] = {}
cur = date.fromisoformat(str(from_date_str))
end = date.fromisoformat(str(to_date_str))
while cur <= end:
date_dict[cur.isoformat()] = 0
cur += timedelta(days=1)
for row in rows:
date_dict[str(row["date"])] = row["count"]
return [{"date": d, "count": c} for d, c in sorted(date_dict.items())]
@frappe.whitelist()
@agent_only
def get_feedback_received(
scope: Scope,
value: str,
) -> dict:
validate_agent_access(scope, value)
Ticket = DocType("HD Ticket")
# Rating distribution grouped by star (1–5)
rows = (
frappe.qb.from_(Ticket)
.select(Ticket.feedback_rating.as_("rating"), Count(Ticket.name).as_("count"))
.where(Ticket.feedback_rating > 0)
.groupby(Ticket.feedback_rating)
.orderby(Ticket.feedback_rating)
)
rows = _apply_scope(rows, Ticket, scope, value)
rows = rows.run(as_dict=True)
# Build {1:n, 2:n, 3:n, 4:n, 5:n} — fill missing stars with 0
distribution: dict[int, int] = dict.fromkeys(range(1, 6), 0)
total_reviews = 0
weighted_sum = 0
for row in rows:
star = int(round(float(row["rating"]) * 5))
star = max(1, min(5, star))
distribution[star] = distribution.get(star, 0) + int(row["count"])
total_reviews += int(row["count"])
weighted_sum += star * int(row["count"])
average_rating = round(weighted_sum / total_reviews, 1) if total_reviews else 0.0
return {
"data": distribution,
"average": average_rating,
"total": total_reviews,
}
@frappe.whitelist()
@agent_only
def get_sla_violations(
dt: str,
dn: str,
period: str = "last month",
) -> dict:
return _get_sla_violations(_resolve_scope(dt), dn, period)
def _get_sla_violations(
scope: Scope,
value: str,
period: str = "last month",
) -> dict:
from datetime import datetime
from dateutil.relativedelta import relativedelta
days = periods.get(period, 7)
# Option B: adaptive bucketing
# last week (7d) → daily → 7 bars
# last month (30d) → weekly → ~4 bars
# last 3 months (90d) → weekly → ~13 bars
# last 6 months (180d) → monthly → 6 bars
if days <= 7:
bucket = "daily"
elif days <= 90:
bucket = "weekly"
else:
bucket = "monthly"
current_from = add_days(nowdate(), -(days - 1))
current_to = nowdate()
previous_from = add_days(nowdate(), -(2 * days - 1))
previous_to = add_days(nowdate(), -days)
def _query(from_date, to_date, grouped=False):
Ticket = DocType("HD Ticket")
to_date_plus_one = Function(
"DATE_ADD", to_date, frappe.qb.terms.PseudoColumn("INTERVAL 1 DAY")
)
if grouped:
if bucket == "daily":
key = Function("DATE", Ticket.creation)
sort = key
elif bucket == "weekly":
# ISO year-week e.g. "2026-12"
key = Function("DATE_FORMAT", Ticket.creation, "%x-%v")
sort = key
else: # monthly
key = Function("DATE_FORMAT", Ticket.creation, "%b %Y")
sort = Function("DATE_FORMAT", Ticket.creation, "%Y%m")
q = (
frappe.qb.from_(Ticket)
.select(key.as_("date"), Count(Ticket.name).as_("count"))
.groupby(sort)
.orderby(sort)
)
else:
q = frappe.qb.from_(Ticket).select(Count(Ticket.name).as_("count"))
q = (
q.where(Ticket.creation >= from_date)
.where(Ticket.creation < to_date_plus_one)
.where(Ticket.agreement_status == "Failed")
)
q = _apply_scope(q, Ticket, scope, value)
return q.run(as_dict=True)
current_rows = _query(current_from, current_to, grouped=True)
current_total = sum(row["count"] for row in current_rows)
previous_total = (_query(previous_from, previous_to) or [{"count": 0}])[0]["count"]
# Build filled series with 0s for missing buckets
now = datetime.now()
if bucket == "daily":
data = _fill_date_series(current_from, current_to, current_rows)
elif bucket == "weekly":
# Build ~4 week buckets: label "Mar 2 – Mar 8"
week_dict: dict[str, dict] = {}
cur = date.fromisoformat(str(current_from))
end = date.fromisoformat(str(current_to))
# Align to Monday of the first week
cur -= timedelta(days=cur.weekday())
while cur <= end:
iso = cur.strftime("%G-%V") # matches %x-%v from MySQL
label = f"{cur.strftime('%b %-d')} – {(cur + timedelta(days=6)).strftime('%b %-d')}"
week_dict[iso] = {"label": label, "count": 0}
cur += timedelta(weeks=1)
for row in current_rows:
key = str(row["date"])
if key in week_dict:
week_dict[key]["count"] = row["count"]
data = [{"date": v["label"], "count": v["count"]} for v in week_dict.values()]
else: # monthly
num_months = days // 30
month_dict: dict[str, int] = {}
for i in range(num_months - 1, -1, -1):
m = now - relativedelta(months=i)
month_dict[m.strftime("%b %Y")] = 0
for row in current_rows:
key = str(row["date"])
if key in month_dict:
month_dict[key] = row["count"]
data = [{"date": label, "count": count} for label, count in month_dict.items()]
return {
"data": data,
"total": current_total,
"percentage_change": calculate_percentage_change(current_total, previous_total),
}
@frappe.whitelist()
@agent_only
def get_avg_first_response_time(
dt: str,
dn: str,
period: str = "last month",
) -> dict:
return get_avg_time_metric(period, "first_response_time", _resolve_scope(dt), dn)
@frappe.whitelist()
@agent_only
def get_avg_resolution_time(
dt: str,
dn: str,
period: str = "last month",
) -> dict:
return get_avg_time_metric(period, "resolution_time", _resolve_scope(dt), dn)
@frappe.whitelist()
@agent_only
def get_ticket_stats(
dt: str,
dn: str,
period: str = "last month",
) -> dict:
scope = _resolve_scope(dt)
stats: dict = {}
stats["feedback_received"] = get_feedback_received(scope, dn)
stats["sla_violations"] = _get_sla_violations(scope, dn, period)
stats["avg_first_response_time"] = get_avg_time_metric(
period, "first_response_time", scope, dn
)
stats["avg_resolution_time"] = get_avg_time_metric(
period, "resolution_time", scope, dn
)
return stats
@@ -1,8 +0,0 @@
// Copyright (c) 2023, Frappe Technologies and contributors
// For license information, please see license.txt
// frappe.ui.form.on("HD Action", {
// refresh(frm) {
// },
// });
@@ -1,110 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2023-08-30 03:05:16.121736",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"is_enabled",
"column_break_hzgg",
"button_section",
"button_label",
"button_icon",
"is_external_link",
"section_break_nbjl",
"action",
"cond_hidden"
],
"fields": [
{
"fieldname": "button_section",
"fieldtype": "Section Break",
"label": "Button"
},
{
"fieldname": "section_break_nbjl",
"fieldtype": "Section Break"
},
{
"fieldname": "cond_hidden",
"fieldtype": "Code",
"label": "Hide condition",
"options": "Javascript"
},
{
"fieldname": "button_label",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Label",
"reqd": 1
},
{
"fieldname": "button_icon",
"fieldtype": "Data",
"label": "Icon"
},
{
"default": "0",
"fieldname": "is_external_link",
"fieldtype": "Check",
"label": "External link"
},
{
"description": "Action to perform when clicking the button. The doc will be available as `this`",
"fieldname": "action",
"fieldtype": "Code",
"label": "On click (Javascript)",
"options": "Javascript",
"reqd": 1
},
{
"default": "0",
"fieldname": "is_enabled",
"fieldtype": "Check",
"label": "Enabled"
},
{
"fieldname": "column_break_hzgg",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-08-30 03:45:51.830906",
"modified_by": "Administrator",
"module": "Helpdesk",
"name": "HD Action",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Agent",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "button_label"
}
@@ -1,9 +0,0 @@
# Copyright (c) 2023, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class HDAction(Document):
pass
@@ -1,9 +0,0 @@
# Copyright (c) 2023, Frappe Technologies and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestHDAction(FrappeTestCase):
pass
@@ -1,147 +1,138 @@
{
"actions": [],
"autoname": "hash",
"creation": "2022-02-08 18:13:42.994713",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"title",
"status",
"published_on",
"views",
"column_break_7",
"category",
"author",
"title_slug",
"content_section",
"content"
],
"fields": [
{
"allow_in_quick_entry": 1,
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Title",
"reqd": 1
},
{
"fieldname": "category",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Category",
"options": "HD Article Category"
},
{
"allow_in_quick_entry": 1,
"fieldname": "content_section",
"fieldtype": "Section Break",
"label": "Content"
},
{
"allow_in_quick_entry": 1,
"fieldname": "content",
"fieldtype": "Text Editor",
"label": "Content"
},
{
"depends_on": "author",
"fieldname": "author",
"fieldtype": "Link",
"label": "Author",
"options": "User",
"read_only": 1
},
{
"depends_on": "eval:doc.status === \"Published\"",
"fieldname": "published_on",
"fieldtype": "Datetime",
"label": "Published On",
"read_only": 1
},
{
"fieldname": "column_break_7",
"fieldtype": "Column Break"
},
{
"default": "Draft",
"fieldname": "status",
"fieldtype": "Select",
"in_standard_filter": 1,
"label": "Status",
"options": "Published\nDraft\nArchived"
},
{
"fieldname": "title_slug",
"fieldtype": "Data",
"is_virtual": 1,
"label": "Title Slug"
},
{
"default": "0",
"fieldname": "views",
"fieldtype": "Int",
"label": "Views",
"read_only": 1
}
],
"links": [],
"make_attachments_public": 1,
"modified": "2026-03-24 20:11:47.835393",
"modified_by": "Administrator",
"module": "Helpdesk",
"name": "HD Article",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Agent",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "All",
"share": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Guest",
"share": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
"actions": [],
"autoname": "hash",
"creation": "2022-02-08 18:13:42.994713",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"title",
"status",
"published_on",
"views",
"column_break_7",
"category",
"author",
"title_slug",
"content_section",
"content"
],
"fields": [
{
"allow_in_quick_entry": 1,
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Title",
"reqd": 1
},
{
"fieldname": "category",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Category",
"options": "HD Article Category"
},
{
"allow_in_quick_entry": 1,
"fieldname": "content_section",
"fieldtype": "Section Break",
"label": "Content"
},
{
"allow_in_quick_entry": 1,
"fieldname": "content",
"fieldtype": "Text Editor",
"label": "Content"
},
{
"depends_on": "author",
"fieldname": "author",
"fieldtype": "Link",
"label": "Author",
"options": "User",
"read_only": 1
},
{
"depends_on": "eval:doc.status === \"Published\"",
"fieldname": "published_on",
"fieldtype": "Datetime",
"label": "Published On",
"read_only": 1
},
{
"fieldname": "column_break_7",
"fieldtype": "Column Break"
},
{
"default": "Draft",
"fieldname": "status",
"fieldtype": "Select",
"in_standard_filter": 1,
"label": "Status",
"options": "Published\nDraft\nArchived"
},
{
"fieldname": "title_slug",
"fieldtype": "Data",
"is_virtual": 1,
"label": "Title Slug"
},
{
"default": "0",
"fieldname": "views",
"fieldtype": "Int",
"label": "Views",
"read_only": 1
}
],
"links": [],
"make_attachments_public": 1,
"modified": "2026-03-24 20:11:47.835393",
"modified_by": "Administrator",
"module": "Helpdesk",
"name": "HD Article",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Agent",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "HD Customer",
"share": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
@@ -37,7 +37,7 @@
}
],
"links": [],
"modified": "2026-02-02 17:13:11.729822",
"modified": "2026-03-24 10:38:20.749980",
"modified_by": "Administrator",
"module": "Helpdesk",
"name": "HD Article Category",
@@ -71,11 +71,7 @@
},
{
"read": 1,
"role": "All"
},
{
"read": 1,
"role": "Guest"
"role": "HD Customer"
}
],
"row_format": "Dynamic",
@@ -34,7 +34,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-02-02 14:17:57.949674",
"modified": "2026-03-24 10:39:16.296257",
"modified_by": "Administrator",
"module": "Helpdesk",
"name": "HD Article Feedback",
@@ -57,7 +57,7 @@
"delete": 1,
"if_owner": 1,
"read": 1,
"role": "All",
"role": "HD Customer",
"write": 1
}
],
@@ -1,7 +1,14 @@
// Copyright (c) 2022, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on("HD Customer", {
// refresh: function (frm) {
// },
refresh: function (frm) {
frm.set_query("primary_contact", function () {
return {
filters: {
name: [
"in",
(frm.doc.contacts || []).map((c) => c.contact_name).filter(Boolean),
],
},
};
});
},
});
@@ -9,8 +9,16 @@
"field_order": [
"image",
"customer_name",
"customer_type",
"domain",
"erpnext_customer",
"country",
"column_break_fead",
"primary_contact",
"email_id",
"mobile_no",
"section_break_fxor",
"contacts",
"references_tab",
"contact_html"
],
@@ -26,6 +34,13 @@
"label": "Customer Name",
"unique": 1
},
{
"default": "Company",
"fieldname": "customer_type",
"fieldtype": "Select",
"label": "Customer Type",
"options": "Company\nIndividual\nPartnership"
},
{
"fieldname": "domain",
"fieldtype": "Data",
@@ -49,12 +64,56 @@
"hidden": 1,
"label": "ERPNext Customer",
"read_only": 1
},
{
"fieldname": "contacts",
"fieldtype": "Table",
"label": "Contacts",
"options": "HD Customer Member"
},
{
"fieldname": "column_break_fead",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_fxor",
"fieldtype": "Section Break"
},
{
"description": "Mobile no. of the primary contact",
"fetch_from": "primary_contact.mobile_no",
"fieldname": "mobile_no",
"fieldtype": "Data",
"label": "Mobile No",
"options": "Phone",
"read_only": 1
},
{
"description": "Email address of the primary contact",
"fetch_from": "primary_contact.email_id",
"fieldname": "email_id",
"fieldtype": "Data",
"label": "Email ID",
"options": "Email",
"read_only": 1
},
{
"fieldname": "primary_contact",
"fieldtype": "Link",
"label": "Primary Contact",
"options": "Contact"
},
{
"fieldname": "country",
"fieldtype": "Link",
"label": "Country",
"options": "Country"
}
],
"image_field": "image",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-05-16 19:18:37.792462",
"modified": "2026-06-20 15:34:00.998330",
"modified_by": "Administrator",
"module": "Helpdesk",
"name": "HD Customer",
@@ -69,33 +128,67 @@
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"role": "Administrator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"select": 1,
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Agent Manager",
"select": 1,
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Agent",
"share": 1,
"write": 1
"select": 1,
"share": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Administrator",
"role": "HD Customer Manager",
"select": 1,
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "HD Customer",
"select": 1,
"share": 1
}
],
"row_format": "Dynamic",
@@ -2,7 +2,10 @@
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.core.api.user_invitation import invite_by_email
from frappe.model.document import Document
from frappe.utils import validate_email_address
from helpdesk.integrations.erpnext.utils import (
cascade_rename,
@@ -14,6 +17,9 @@ from helpdesk.integrations.erpnext.utils import (
sync_related_fields,
validate_rename_conflict,
)
from helpdesk.utils import agent_only, get_customers, is_agent
CUSTOMER_ROLES = ("HD Customer", "HD Customer Manager")
class HDCustomer(Document):
@@ -27,9 +33,21 @@ class HDCustomer(Document):
"type": "Data",
},
{
"label": "Domain",
"key": "domain",
"width": "24rem",
"label": "Customer Type",
"key": "customer_type",
"width": "10rem",
"type": "Select",
},
{
"label": "Email ID",
"key": "email_id",
"width": "17rem",
"type": "Data",
},
{
"label": "Mobile No",
"key": "mobile_no",
"width": "12rem",
"type": "Data",
},
{
@@ -39,9 +57,319 @@ class HDCustomer(Document):
"type": "Datetime",
},
]
rows = ["name", "domain", "image", "creation", "owner"]
rows = [
"customer_name",
"customer_type",
"email_id",
"mobile_no",
"creation",
"image",
]
return {"columns": columns, "rows": rows}
def validate(self):
self.validate_contacts()
def validate_contacts(self):
if not self.contacts:
return
contact_names = [contact.contact_name for contact in self.contacts]
if len(contact_names) != len(set(contact_names)):
frappe.throw(_("Duplicate contacts are not allowed"))
if self.primary_contact and self.primary_contact not in contact_names:
frappe.throw(_("Primary contact must be one of the listed contacts"))
def before_save(self):
self.ensure_primary_contact_is_manager()
self.handle_roles()
self.cleanup_primary_contact_fields()
def ensure_primary_contact_is_manager(self):
# the primary is optional, but whoever is set as primary is a manager
if not self.contacts or not self.primary_contact:
return
for contact in self.contacts:
if contact.contact_name == self.primary_contact:
contact.is_manager = True
def handle_roles(self):
# Sync customer roles only for contacts whose manager flag actually changed
if not self.has_value_changed("contacts"):
return
previous = self.get_doc_before_save()
was_manager = (
{row.contact_name: row.is_manager for row in previous.contacts}
if previous
else {}
)
for contact in self.contacts:
if contact.is_manager == was_manager.get(contact.contact_name):
continue
self.sync_contact_role(contact)
def sync_contact_role(self, contact):
"""Grant the manager/member role to a single contact's user in one save."""
user = self.get_user(contact.contact_name, throw_error=False)
if not user:
return
user_doc = frappe.get_doc("User", user)
if contact.is_manager:
user_doc.append_roles("HD Customer Manager", "HD Customer")
else:
user_doc.append_roles("HD Customer")
if not self.is_manager_in_other_customers(contact.contact_name):
user_doc.set(
"roles",
[r for r in user_doc.roles if r.role != "HD Customer Manager"],
)
user_doc.save(ignore_permissions=True)
def is_manager_in_other_customers(self, contact_name: str) -> bool:
"""Whether the contact manages a customer other than this one. If yes, dont demote the role, just remove the is_manager check."""
return bool(
frappe.db.exists(
"HD Customer Member",
{
"contact_name": contact_name,
"is_manager": 1,
"parent": ("!=", self.name),
},
)
)
def cleanup_primary_contact_fields(self):
"""Clear the email and mobile fields if the primary contact is removed."""
if not self.has_value_changed("contacts"):
return
if not len(self.contacts) and self.primary_contact:
self.primary_contact = None
self.email_id = None
self.mobile_no = None
return
def get_user(self, contact_name, throw_error=True):
user = frappe.db.get_value("Contact", contact_name, "user")
if user:
return user
# fallback to email if user is not set for the contact
email = frappe.get_value("Contact", contact_name, "email_id")
if email:
if user := frappe.db.exists("User", email):
frappe.db.set_value("Contact", contact_name, "user", user)
return user
elif throw_error:
frappe.throw(_("No user linked to contact {0}").format(contact_name))
else:
return None
@frappe.whitelist()
@agent_only
def get_contacts(self):
contact_names = [contact.contact_name for contact in self.contacts]
details = self.get_contact_details(contact_names)
ticket_counts = self.get_pending_ticket_counts(contact_names)
last_active = self.get_last_active([d.user for d in details.values() if d.user])
result = []
for contact in self.contacts:
info = details.get(contact.contact_name)
if not info:
continue
result.append(
{
"contact_name": contact.contact_name,
"is_primary": int(contact.contact_name == self.primary_contact),
"is_manager": contact.is_manager,
"email_id": info.email_id,
"mobile_no": info.mobile_no or info.phone,
"image": info.image,
"last_active": last_active.get(info.user),
"ticket_count": ticket_counts.get(contact.contact_name, 0),
}
)
result.sort(key=lambda row: (not row["is_primary"], not row["is_manager"]))
return result
def get_contact_details(self, contact_names: list[str]) -> dict:
"""Fetch every member's contact info in one query, keyed by contact name."""
if not contact_names:
return {}
rows = frappe.get_all(
"Contact",
filters={"name": ["in", contact_names]},
fields=["name", "email_id", "mobile_no", "phone", "image", "user"],
)
return {row.name: row for row in rows}
def get_pending_ticket_counts(self, contact_names: list[str]) -> dict:
"""Count open/paused tickets per contact (matched by raised_by or contact)."""
counts = dict.fromkeys(contact_names, 0)
if not contact_names:
return counts
tickets = frappe.get_list(
"HD Ticket",
filters={"status_category": ["in", ["Open", "Paused"]]},
or_filters=[
["raised_by", "in", contact_names],
["contact", "in", contact_names],
],
fields=["raised_by", "contact"],
)
for ticket in tickets:
for name in {ticket.raised_by, ticket.contact} & counts.keys():
counts[name] += 1
return counts
def get_last_active(self, users: list[str]) -> dict:
"""Map each user to their last_active timestamp in one query."""
if not users:
return {}
rows = frappe.get_all(
"User", filters={"name": ["in", users]}, fields=["name", "last_active"]
)
return {row.name: row.last_active for row in rows}
@frappe.whitelist()
def add_contacts(self, contacts: str | list, role: str = "HD Customer") -> dict:
"""Add contacts that already have a linked user; invite the rest by email.
Each entry can be a Contact name or an email address. Returns the
contact names added directly and the result of the sent invitations.
"""
self.validate_contact_update_permission(role)
entries = frappe.parse_json(contacts) if isinstance(contacts, str) else contacts
if not entries:
frappe.throw(_("No contacts to add"))
added, to_invite = [], []
for entry in entries:
contact_name, email = self.resolve_contact_entry(entry)
if contact_name and self.get_user(contact_name, throw_error=False):
if self.add_contact(contact_name, role):
added.append(contact_name)
else:
to_invite.append({"email": email, "contact": contact_name})
if added:
self.save()
return {"added": added, "invite_result": self.invite_contacts(to_invite, role)}
def validate_contact_update_permission(self, role: str) -> None:
if role not in CUSTOMER_ROLES:
frappe.throw(_("Invalid role {0}").format(role))
roles = frappe.get_roles()
if "System Manager" not in roles and "Agent Manager" not in roles:
frappe.throw(
_("You do not have permission to update contacts"),
frappe.PermissionError,
)
def resolve_contact_entry(self, entry: str) -> tuple[str | None, str]:
"""Resolve an entry to a (contact name, email) pair."""
if frappe.db.exists("Contact", entry):
email = frappe.db.get_value("Contact", entry, "email_id")
if not email:
frappe.throw(_("Contact {0} has no email address").format(entry))
return entry, email
validate_email_address(entry, throw=True)
return frappe.db.get_value("Contact", {"email_id": entry}, "name"), entry
def add_contact(self, contact_name: str, role: str = "HD Customer") -> bool:
"""Add a contact to membership. Returns False if already a member."""
if any(row.contact_name == contact_name for row in self.contacts):
return False
self.append(
"contacts",
{
"contact_name": contact_name,
"is_manager": role == "HD Customer Manager",
},
)
return True
def remove_contact(self, contact_name: str) -> None:
"""Drop a contact from membership, clearing primary state if it was primary."""
self.contacts = [r for r in self.contacts if r.contact_name != contact_name]
if self.primary_contact == contact_name:
self.clear_primary()
def set_primary(self, contact_name: str) -> None:
"""Make a contact the primary contact, ensuring it is a manager member."""
self.add_contact(contact_name, "HD Customer Manager")
self.primary_contact = contact_name
def clear_primary(self) -> None:
"""Drop the primary contact and the fields fetched from it."""
self.primary_contact = None
self.email_id = None
self.mobile_no = None
def invite_contacts(self, to_invite: list[dict], role: str) -> dict:
result = {
"invited_emails": [],
"pending_invite_emails": [],
"accepted_invite_emails": [],
"disabled_user_emails": [],
}
for item in to_invite:
params = {"customer": self.name}
if item["contact"]:
params["contact"] = item["contact"]
response = invite_by_email(
item["email"],
roles=[role],
redirect_to_path="/helpdesk",
app_name="helpdesk",
**params,
)
for key, emails in response.items():
result.setdefault(key, []).extend(emails)
return result
@frappe.whitelist()
def get_pending_invites(self):
if "Agent Manager" not in frappe.get_roles():
frappe.throw(
_("You do not have permission to access this resource"),
frappe.PermissionError,
)
pending_invites = frappe.db.get_all(
"User Invitation",
filters={
"app_name": "helpdesk",
"status": "Pending",
"customer": self.name,
},
fields=[
"name",
"email",
"invited_by",
"creation",
],
)
res = []
for pending_invitation in pending_invites:
roles = frappe.db.get_all(
"User Role",
fields=["role"],
filters={"parent": pending_invitation.name},
)
res.append(
{
"name": pending_invitation.name,
"email": pending_invitation.email,
"invited_by": pending_invitation.invited_by,
"invited_on": pending_invitation.creation,
"roles": [r.role for r in roles],
}
)
return res
def after_insert(self):
if should_sync_with_erpnext() and not self.flags.get("ignore_erpnext_sync"):
self.create_customer_in_erpnext()
@@ -82,6 +410,8 @@ class HDCustomer(Document):
cascade_rename("HD Customer", olddn, newdn, merge)
def on_trash(self):
self.unlink_tickets()
self.unlink_invitations()
if in_cascade() or not should_sync_with_erpnext():
return
@@ -93,3 +423,65 @@ class HDCustomer(Document):
# delete this HD Customer again (it's already being deleted).
frappe.db.set_value("Customer", erpnext_customer, "hd_customer", None)
frappe.delete_doc("Customer", erpnext_customer, ignore_permissions=True)
def unlink_tickets(self) -> None:
"""Delete or detach the tickets linked to this customer on delete."""
if self.flags.get("delete_tickets"):
self.delete_linked_tickets()
else:
self.unlink_linked_tickets()
def delete_linked_tickets(self) -> None:
tickets = frappe.get_all("HD Ticket", {"customer": self.name}, pluck="name")
for ticket in tickets:
frappe.delete_doc("HD Ticket", ticket, ignore_permissions=True)
def unlink_linked_tickets(self) -> None:
frappe.db.set_value("HD Ticket", {"customer": self.name}, "customer", None)
def unlink_invitations(self) -> None:
frappe.db.set_value(
"User Invitation",
{"app_name": "helpdesk", "customer": self.name},
"customer",
None,
)
# Custom perms for list query. Only the `WHERE` part
# Used so that contact raising the ticket can see the customers they are part of
def permission_query(user: str) -> str:
if is_agent(user):
return ""
customers = get_customers(user)
if not customers:
return "1=0"
names = ", ".join(frappe.db.escape(name) for name in customers)
return f"`tabHD Customer`.name in ({names})"
def has_permission(doc: Document, ptype: str, user: str) -> bool:
"""Per-record access for HD Customer.
Members of a customer can read it; only managers can write or delete it.
Agents are unrestricted; non-members are denied.
"""
if is_agent(user):
return True
membership = get_customer_membership(doc.name, user)
if not membership:
return False
if ptype in ("write", "delete"):
return bool(membership.get("is_manager"))
return True
def get_customer_membership(customer: str, user: str) -> dict | None:
"""Return the user's membership row for `customer`, or None if not a member."""
for member in get_customers(user, get_roles=True):
if member.get("name") == customer:
return member
return None
@@ -1,8 +1,423 @@
# Copyright (c) 2022, Frappe Technologies and Contributors
# See license.txt
from frappe.tests.utils import FrappeTestCase
import frappe
from frappe.tests import IntegrationTestCase
from helpdesk.helpdesk.doctype.hd_customer.hd_customer import (
has_permission,
permission_query,
)
from helpdesk.helpdesk.hooks.user_invitation import after_accept
from helpdesk.test_utils import (
add_contact_in_customer,
create_contact,
create_customer,
create_user,
get_invitation,
make_ticket,
update_role_in_customer,
)
class TestHDCustomer(FrappeTestCase):
pass
class TestHDCustomer(IntegrationTestCase):
def setUp(self) -> None:
frappe.set_user("Administrator")
def tearDown(self) -> None:
frappe.set_user("Administrator")
def test_contact_with_linked_user_is_added_directly(self) -> None:
customer = create_customer("Test Customer Direct Add")
contact = create_contact("Linked", "[email protected]")
result = customer.add_contacts([contact["contact"]], "HD Customer")
self.assertIn(contact["contact"], result["added"])
self.assertEqual(result["invite_result"]["invited_emails"], [])
member_names = [row.contact_name for row in customer.contacts]
self.assertIn(contact["contact"], member_names)
self.assertFalse(get_invitation(contact["user"]), "No invitation expected")
def test_contact_without_user_gets_invited_not_added(self) -> None:
customer = create_customer("Test Customer Invite Contact")
email = "[email protected]"
contact = create_contact("Unlinked", email, user=False)
result = customer.add_contacts([contact["contact"]], "HD Customer")
self.assertFalse(result["added"])
self.assertIn(email, result["invite_result"]["invited_emails"])
member_names = [row.contact_name for row in customer.contacts]
self.assertNotIn(contact["contact"], member_names)
self.assertFalse(
frappe.db.exists("User", email),
"No user should be auto created for an unlinked contact",
)
invitation = get_invitation(email)
self.assertEqual(invitation.customer, customer.name)
self.assertEqual(invitation.contact, contact["contact"])
def test_unknown_email_gets_invited(self) -> None:
customer = create_customer("Test Customer Invite Email")
result = customer.add_contacts(["[email protected]"], "HD Customer")
self.assertIn(
"[email protected]", result["invite_result"]["invited_emails"]
)
invitation = get_invitation("[email protected]")
self.assertEqual(invitation.customer, customer.name)
def test_duplicate_contact_is_not_added_twice(self) -> None:
customer = create_customer("Test Customer Duplicate")
contact = create_contact("Duplicate", "[email protected]")
customer.add_contacts([contact["contact"]], "HD Customer")
result = customer.add_contacts([contact["contact"]], "HD Customer")
self.assertFalse(result["added"])
member_names = [row.contact_name for row in customer.contacts]
self.assertEqual(member_names.count(contact["contact"]), 1)
def test_manager_role_is_applied_on_add(self) -> None:
customer = create_customer("Test Customer Manager Role")
contact = create_contact("Manager", "[email protected]")
customer.add_contacts([contact["contact"]], "HD Customer Manager")
member = next(
row for row in customer.contacts if row.contact_name == contact["contact"]
)
self.assertTrue(member.is_manager)
self.assertIn("HD Customer Manager", frappe.get_roles(contact["user"]))
def test_add_contacts_requires_manager_roles(self) -> None:
customer = create_customer("Test Customer Permission")
user = create_user("[email protected]")
frappe.set_user(user.name)
with self.assertRaises(frappe.PermissionError):
customer.add_contacts(["[email protected]"], "HD Customer")
def test_save_does_not_auto_create_user(self) -> None:
customer = create_customer("Test Customer No Auto User")
email = "[email protected]"
contact = create_contact("No User", email, user=False)
customer.append("contacts", {"contact_name": contact["contact"]})
customer.save()
self.assertFalse(frappe.db.exists("User", email))
self.assertFalse(get_invitation(email))
def test_accepted_invitation_links_contact_to_customer(self) -> None:
customer = create_customer("Test Customer Accept Invite")
email = "[email protected]"
customer.add_contacts([email], "HD Customer")
invitation = frappe.get_doc("User Invitation", get_invitation(email).name)
user = create_user(email)
frappe.set_user(user.name)
after_accept(invitation, user, user_inserted=True)
frappe.set_user("Administrator")
customer.reload()
contact = frappe.db.get_value("Contact", {"user": user.name, "email_id": email})
member_names = [row.contact_name for row in customer.contacts]
self.assertIn(contact, member_names)
def test_accepted_manager_invitation_sets_is_manager(self) -> None:
customer = create_customer("Test Customer Accept Manager Invite")
email = "[email protected]"
customer.add_contacts([email], "HD Customer Manager")
invitation = frappe.get_doc("User Invitation", get_invitation(email).name)
user = create_user(email)
frappe.set_user(user.name)
after_accept(invitation, user, user_inserted=True)
frappe.set_user("Administrator")
customer.reload()
contact = frappe.db.get_value("Contact", {"user": user.name, "email_id": email})
member = next(
(row for row in customer.contacts if row.contact_name == contact), None
)
self.assertIsNotNone(member, "Invited manager should be a member after accept")
self.assertTrue(
member.is_manager, "Accepted manager invite must set is_manager"
)
self.assertIn("HD Customer Manager", frappe.get_roles(user.name))
def test_setting_primary_contact_makes_them_manager(self) -> None:
customer = create_customer("Test Customer Primary Manager")
first = create_contact("First", "[email protected]")
second = create_contact("Second", "[email protected]")
# no primary yet, so neither contact is a manager
customer.add_contacts([first["contact"], second["contact"]], "HD Customer")
self.assertNotIn("HD Customer Manager", frappe.get_roles(second["user"]))
# designating a primary promotes that contact to manager
customer.primary_contact = second["contact"]
customer.save()
member = next(
row for row in customer.contacts if row.contact_name == second["contact"]
)
self.assertTrue(member.is_manager)
self.assertIn("HD Customer Manager", frappe.get_roles(second["user"]))
def test_customer_roles_follow_is_manager(self) -> None:
customer = create_customer("Test Customer Roles")
manager = create_contact("RoleManager", "[email protected]")
member = create_contact("RoleMember", "[email protected]")
customer.add_contacts([manager["contact"]], "HD Customer Manager")
customer.add_contacts([member["contact"]], "HD Customer")
self.assertIn("HD Customer Manager", frappe.get_roles(manager["user"]))
member_roles = frappe.get_roles(member["user"])
self.assertIn("HD Customer", member_roles)
self.assertNotIn("HD Customer Manager", member_roles)
# promote the non-primary member, then demote; the manager role is revoked
update_role_in_customer(customer, member["contact"], "HD Customer Manager")
self.assertIn("HD Customer Manager", frappe.get_roles(member["user"]))
update_role_in_customer(customer, member["contact"], "HD Customer")
demoted_roles = frappe.get_roles(member["user"])
self.assertNotIn("HD Customer Manager", demoted_roles)
self.assertIn("HD Customer", demoted_roles)
def test_manager_role_survives_demotion_when_manager_elsewhere(self) -> None:
# A contact managing two customers keeps the global HD Customer Manager
# role when demoted in one, and loses it only when demoted in both.
customer_a = create_customer("Test Manager Union A")
customer_b = create_customer("Test Manager Union B")
contact = create_contact("UnionManager", "[email protected]")
add_contact_in_customer(customer_a, contact["contact"], is_manager=True)
add_contact_in_customer(customer_b, contact["contact"], is_manager=True)
self.assertIn("HD Customer Manager", frappe.get_roles(contact["user"]))
# demote in A only: still a manager of B, so the role is retained
update_role_in_customer(customer_a, contact["contact"], "HD Customer")
self.assertIn("HD Customer Manager", frappe.get_roles(contact["user"]))
# demote in B too: manager nowhere, role revoked (HD Customer stays)
update_role_in_customer(customer_b, contact["contact"], "HD Customer")
roles = frappe.get_roles(contact["user"])
self.assertNotIn("HD Customer Manager", roles)
self.assertIn("HD Customer", roles)
def test_duplicate_contacts_are_rejected_on_save(self) -> None:
customer = create_customer("Test Customer Duplicate Validate")
contact = create_contact("DupValidate", "[email protected]", user=False)
customer.append("contacts", {"contact_name": contact["contact"]})
customer.append("contacts", {"contact_name": contact["contact"]})
with self.assertRaises(frappe.ValidationError):
customer.save()
def test_create_customer_with_primary_contact(self) -> None:
from helpdesk.api.customer import create_customer as create_customer_api
email = "[email protected]"
cleanup_customer_and_contact("Test Customer API Primary", email)
name = create_customer_api(
{"customer_name": "Test Customer API Primary", "customer_type": "Company"},
{"first_name": "Primary", "last_name": "Contact", "email": email},
)
customer = frappe.get_doc("HD Customer", name)
contact = frappe.db.get_value("Contact", {"email_id": email}, "name")
self.assertEqual(customer.customer_type, "Company")
self.assertEqual(customer.primary_contact, contact)
member = next(row for row in customer.contacts if row.contact_name == contact)
self.assertTrue(member.is_manager)
invitation = get_invitation(email)
self.assertEqual(invitation.customer, name)
self.assertEqual(invitation.contact, contact)
def test_primary_contact_not_duplicated_after_accept(self) -> None:
from helpdesk.api.customer import create_customer as create_customer_api
email = "[email protected]"
cleanup_customer_and_contact("Test Customer API Accept", email)
name = create_customer_api(
{"customer_name": "Test Customer API Accept", "customer_type": "Company"},
{"first_name": "Primary", "email": email},
)
invitation = frappe.get_doc("User Invitation", get_invitation(email).name)
user = create_user(email)
frappe.set_user(user.name)
after_accept(invitation, user, user_inserted=True)
frappe.set_user("Administrator")
customer = frappe.get_doc("HD Customer", name)
contact = frappe.db.get_value("Contact", {"email_id": email}, "name")
member_names = [row.contact_name for row in customer.contacts]
self.assertEqual(member_names.count(contact), 1)
def test_delete_customer_unlinks_tickets_and_invitations(self) -> None:
from helpdesk.api.customer import delete_customer
email = "[email protected]"
contact, customer, ticket, invitation = self.setup_customer_for_delete(
"Test Customer Unlink", email
)
delete_customer(customer.name)
self.assertFalse(frappe.db.exists("HD Customer", customer.name))
ticket.reload()
self.assertIsNone(ticket.customer)
invitation.reload()
self.assertIsNone(invitation.customer)
self.assertTrue(frappe.db.exists("Contact", contact["contact"]))
def test_delete_customer_deletes_linked_tickets(self) -> None:
from helpdesk.api.customer import delete_customer
email = "[email protected]"
contact, customer, ticket, invitation = self.setup_customer_for_delete(
"Test Customer Delete Tickets", email
)
delete_customer(customer.name, delete_tickets=True)
self.assertFalse(frappe.db.exists("HD Customer", customer.name))
self.assertFalse(frappe.db.exists("HD Ticket", ticket.name))
invitation.reload()
self.assertIsNone(invitation.customer)
self.assertTrue(frappe.db.exists("Contact", contact["contact"]))
def setup_customer_for_delete(self, customer_name: str, email: str):
for inv in frappe.db.get_all("User Invitation", {"email": email}, pluck="name"):
frappe.delete_doc("User Invitation", inv, force=True)
contact_doc = create_contact("DeleteLink", email, user=False)
customer = create_customer(
customer_name, [{"contact_name": contact_doc["contact"]}]
)
ticket = make_ticket(customer=customer.name, contact=contact_doc["contact"])
invitation = frappe.get_doc(
{
"doctype": "User Invitation",
"email": email,
"app_name": "helpdesk",
"redirect_to_path": "/helpdesk",
"roles": [{"role": "HD Customer"}],
"customer": customer.name,
"contact": contact_doc["contact"],
}
).insert(ignore_permissions=True)
return contact_doc, customer, ticket, invitation
def test_has_permission_member_reads_manager_writes(self) -> None:
# Members may read their customer; only managers may write it.
customer = create_customer("Perm Member Manager")
member = create_contact("PermMember", "[email protected]")
manager = create_contact("PermManager", "[email protected]")
add_contact_in_customer(customer, member["contact"], is_manager=False)
add_contact_in_customer(customer, manager["contact"], is_manager=True)
name = customer.name
frappe.set_user(member["user"])
self.assertTrue(frappe.has_permission("HD Customer", "read", name))
self.assertFalse(frappe.has_permission("HD Customer", "write", name))
frappe.set_user(manager["user"])
self.assertTrue(frappe.has_permission("HD Customer", "read", name))
self.assertTrue(frappe.has_permission("HD Customer", "write", name))
def test_has_permission_denies_non_member(self) -> None:
# A portal user who is not a member is denied read and write (IDOR guard).
customer = create_customer("Perm Non Member")
outsider = create_contact("PermOutsider", "[email protected]")
name = customer.name
frappe.set_user(outsider["user"])
self.assertFalse(frappe.has_permission("HD Customer", "read", name))
self.assertFalse(frappe.has_permission("HD Customer", "write", name))
def test_has_permission_per_record_role(self) -> None:
# Manager of one customer who is only a member of another can write the
# first but not the second; the role is bound per record, not per user.
customer_a = create_customer("Perm Record A")
customer_b = create_customer("Perm Record B")
contact_in_dual_customers = create_contact("PermDual", "[email protected]")
add_contact_in_customer(
customer_a, contact_in_dual_customers["contact"], is_manager=True
)
add_contact_in_customer(
customer_b, contact_in_dual_customers["contact"], is_manager=False
)
user = contact_in_dual_customers["user"]
frappe.set_user(user)
self.assertTrue(has_permission(customer_a, "read", user))
self.assertTrue(has_permission(customer_a, "write", user))
self.assertTrue(has_permission(customer_b, "read", user))
self.assertFalse(has_permission(customer_b, "write", user))
def test_permission_query_scopes_list(self) -> None:
# Agents are unrestricted, members see only their customers, others none.
agent = create_user("[email protected]")
agent.add_roles("Agent")
stranger = create_user("[email protected]")
member = create_contact("PermQueryMember", "[email protected]")
mine = create_customer("O'Brien Inc") # apostrophe exercises SQL escaping
other = create_customer("Perm Query Other")
add_contact_in_customer(mine, member["contact"], is_manager=False)
frappe.set_user(agent.name)
self.assertEqual(permission_query(agent.name), "")
frappe.set_user(stranger.name)
self.assertEqual(permission_query(stranger.name), "1=0")
frappe.set_user(member["user"])
names = frappe.get_list("HD Customer", pluck="name")
frappe.set_user("Administrator")
self.assertIn(mine.name, names)
self.assertNotIn(other.name, names)
def test_has_permission_follows_live_role_change(self) -> None:
# Write access tracks the live is_manager flag, not a cached role.
# update_role_in_customer()
customer = create_customer("Perm Live Change")
member = create_contact("PermLive", "[email protected]")
add_contact_in_customer(customer, member["contact"], is_manager=False)
user = member["user"]
frappe.set_user(user)
self.assertFalse(has_permission(customer, "write", user))
update_role_in_customer(customer, member["contact"], "HD Customer Manager")
frappe.set_user(user)
self.assertTrue(has_permission(customer, "write", user))
update_role_in_customer(customer, member["contact"], "HD Customer")
frappe.set_user(user)
self.assertFalse(has_permission(customer, "write", user))
def test_primary_contact_must_be_a_listed_contact(self) -> None:
customer = create_customer("Test Customer Primary Validate")
contact = create_contact(
"PrimaryValidate", "[email protected]", user=False
)
customer.append("contacts", {"contact_name": contact["contact"]})
customer.primary_contact = "Non Existent Contact"
with self.assertRaises(frappe.ValidationError):
customer.save()
def cleanup_customer_and_contact(customer_name: str, email: str) -> None:
if frappe.db.exists("HD Customer", customer_name):
frappe.delete_doc("HD Customer", customer_name, force=True)
for c in frappe.db.get_all("Contact", {"email_id": email}, pluck="name"):
frappe.delete_doc("Contact", c, force=True)
if frappe.db.exists("User", email):
frappe.delete_doc("User", email, force=True)
@@ -0,0 +1,44 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2026-02-20 11:46:06.430007",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"contact_name",
"is_manager"
],
"fields": [
{
"default": "0",
"fieldname": "is_manager",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Is Manager"
},
{
"fieldname": "contact_name",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Name",
"options": "Contact",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-02-20 17:04:23.230132",
"modified_by": "Administrator",
"module": "Helpdesk",
"name": "HD Customer Member",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
@@ -1,9 +1,9 @@
# Copyright (c) 2022, Frappe Technologies and contributors
# Copyright (c) 2026, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class HDOrganization(Document):
class HDCustomerMember(Document):
pass
@@ -1,7 +0,0 @@
// Copyright (c) 2022, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on("HD Desk Account Request", {
// refresh: function(frm) {
// }
});
@@ -1,61 +0,0 @@
{
"actions": [],
"creation": "2022-04-11 07:48:40.788266",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"request_key",
"ip_address",
"email",
"user"
],
"fields": [
{
"fieldname": "request_key",
"fieldtype": "Data",
"label": "Request Key"
},
{
"fieldname": "ip_address",
"fieldtype": "Data",
"label": "IP Address"
},
{
"fieldname": "email",
"fieldtype": "Data",
"label": "Email",
"unique": 1
},
{
"fieldname": "user",
"fieldtype": "Link",
"label": "User",
"options": "User"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2022-04-11 07:56:25.283329",
"modified_by": "Administrator",
"module": "Helpdesk",
"name": "HD Desk Account Request",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -1,40 +0,0 @@
import frappe
from frappe.model.document import Document
from frappe.utils import get_url, random_string
class HDDeskAccountRequest(Document):
def before_save(self):
if not self.request_key:
self.request_key = random_string(32)
self.ip_address = frappe.local.request_ip
def after_insert(self):
self.send_verification_email()
def send_verification_email(self):
url = get_url(f"/helpdesk/verify/{self.request_key}")
subject = "Verify your account"
sender = None
if frappe.db.exists(
"Email Account", {"name": "Support", "enable_outgoing": True}
):
sender = frappe.get_doc("Email Account", "Support").email_id
try:
frappe.sendmail(
recipients=self.email,
sender=sender,
subject=subject,
template="email_verification",
# flake8: noqa
args=dict(link=url),
now=True,
)
except Exception:
frappe.throw(
"Either setup up Support email account or there should be a default"
" outgoing email account"
)
@@ -1,9 +0,0 @@
# Copyright (c) 2022, Frappe Technologies and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestHDDeskAccountRequest(FrappeTestCase):
pass
@@ -1,8 +0,0 @@
// Copyright (c) 2023, Frappe Technologies and contributors
// For license information, please see license.txt
// frappe.ui.form.on("HD Escalation Rule", {
// refresh(frm) {
// },
// });
@@ -1,124 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2023-06-19 18:19:04.349990",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"is_enabled",
"column_break_hdwh",
"criterion_section",
"priority",
"team",
"column_break_aabd",
"ticket_type",
"assign_to_section",
"to_agent",
"to_team",
"column_break_ogxx",
"to_priority",
"to_ticket_type"
],
"fields": [
{
"fieldname": "priority",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Priority",
"options": "HD Ticket Priority"
},
{
"fieldname": "team",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Team",
"options": "HD Team"
},
{
"fieldname": "criterion_section",
"fieldtype": "Section Break",
"label": "Criterion"
},
{
"fieldname": "assign_to_section",
"fieldtype": "Section Break",
"label": "Assign to"
},
{
"fieldname": "to_agent",
"fieldtype": "Link",
"label": "Agent",
"options": "HD Agent"
},
{
"fieldname": "column_break_ogxx",
"fieldtype": "Column Break"
},
{
"fieldname": "to_team",
"fieldtype": "Link",
"label": "Team",
"options": "HD Team"
},
{
"fieldname": "to_priority",
"fieldtype": "Link",
"label": "Priority",
"options": "HD Ticket Priority"
},
{
"default": "0",
"fieldname": "is_enabled",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Enabled"
},
{
"fieldname": "column_break_hdwh",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_aabd",
"fieldtype": "Column Break"
},
{
"fieldname": "ticket_type",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Ticket type",
"options": "HD Ticket Type"
},
{
"fieldname": "to_ticket_type",
"fieldtype": "Link",
"label": "Ticket type",
"options": "HD Ticket Type"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-06-19 23:09:37.945913",
"modified_by": "Administrator",
"module": "Helpdesk",
"name": "HD Escalation Rule",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -1,55 +0,0 @@
# Copyright (c) 2023, Frappe Technologies and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
from helpdesk.utils import capture_event, publish_event
class HDEscalationRule(Document):
def validate(self):
self.validate_criterion()
self.validate_duplicate()
def after_insert(self):
self.emit_after_insert()
def on_update(self):
self.emit_on_update()
def after_delete(self):
self.emit_after_delete()
def validate_criterion(self):
if not (self.priority or self.team or self.ticket_type):
frappe.throw(
_("At-least one of priority, team and ticket type is required")
)
def validate_duplicate(self):
is_duplicate = frappe.db.count(
"HD Escalation Rule",
filters={
"name": ["!=", self.name],
"priority": self.priority or "",
"team": self.team or "",
"ticket_type": self.ticket_type or "",
},
)
if is_duplicate:
frappe.throw(_("Escalation rule already exists for this criteria"))
def emit_after_insert(self):
capture_event("escalation_rule_created")
publish_event("helpdesk:new-escalation-rule", self)
def emit_on_update(self):
capture_event("escalation_rule_updated")
publish_event("helpdesk:update-escalation-rule", self)
def emit_after_delete(self):
capture_event("escalation_rule_deleted")
publish_event("helpdesk:delete-escalation-rule", self)

Some files were not shown because too many files have changed in this diff Show More