Merge branch 'develop' of https://github.com/frappe/helpdesk into refactor/customer-contact

This commit is contained in:
Ritvik Sardana
2026-05-27 17:01:53 +05:30
286 changed files with 31595 additions and 23490 deletions
+5
View File
@@ -40,6 +40,7 @@ sed -i 's/schedule:/# schedule:/g' Procfile
sed -i 's/socketio:/# socketio:/g' Procfile
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
bench get-app erpnext --branch "develop"
bench get-app telephony
bench get-app helpdesk "${GITHUB_WORKSPACE}"
bench setup requirements --dev
@@ -48,3 +49,7 @@ bench setup requirements --dev
bench start &>> ~/frappe-bench/bench_start.log &
CI=Yes bench build --app frappe &
bench --site test_site reinstall --yes
bench --verbose --site test_site install-app erpnext
bench --verbose --site test_site install-app telephony
bench --verbose --site test_site install-app helpdesk
+32
View File
@@ -0,0 +1,32 @@
name: Generate Semantic Release
on:
workflow_dispatch:
push:
branches:
- main
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Entire Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
- name: Setup dependencies
run: |
npm install @semantic-release/git @semantic-release/exec --no-save
- name: Create Release
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GIT_AUTHOR_NAME: "Frappe PR Bot"
GIT_AUTHOR_EMAIL: "developers@frappe.io"
GIT_COMMITTER_NAME: "Frappe PR Bot"
GIT_COMMITTER_EMAIL: "developers@frappe.io"
run: npx semantic-release
@@ -27,13 +27,13 @@ jobs:
steps:
- name: Update notes
run: |
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/crm/releases/generate-notes -f tag_name=$RELEASE_TAG \
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/helpdesk/releases/generate-notes -f tag_name=$RELEASE_TAG \
| jq -r '.body' \
| sed -E '/^\* (chore|ci|test|docs|style)/d' \
| sed -E 's/by @mergify //'
)
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/crm/releases/tags/$RELEASE_TAG | jq -r '.id')
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/crm/releases/$RELEASE_ID -f body="$NEW_NOTES"
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/helpdesk/releases/tags/$RELEASE_TAG | jq -r '.id')
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/helpdesk/releases/$RELEASE_ID -f body="$NEW_NOTES"
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+4
View File
@@ -30,6 +30,10 @@ share/python-wheels/
.installed.cfg
*.egg
.playwright-mcp
.claude-flow
.claude
CLAUDE.md
helpdesk/docs/current
helpdesk/public/desk
+21
View File
@@ -0,0 +1,21 @@
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer", {
"preset": "angular"
},
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec", {
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" helpdesk/__init__.py'
}
],
[
"@semantic-release/git", {
"assets": ["helpdesk/__init__.py"],
"message": "chore(release): Bumped to Version ${nextRelease.version}"
}
],
"@semantic-release/github"
]
}
+122
View File
@@ -228,6 +228,47 @@ apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
- Use proper field types and validation in DocType definitions
- Leverage DocType methods for business logic encapsulation
## Controller Code Structure
All controller logic must follow this structure strictly:
**Rule: Lifecycle hooks call methods. Methods are defined below on the class.**
```python
class HDFoo(Document):
# 1. Lifecycle hooks at the top — each hook calls methods, no inline logic
def after_insert(self):
self.do_a()
self.do_b()
def on_update(self):
self.do_a()
# 2. Methods below — one responsibility each
def do_a(self):
pass
def do_b(self):
pass
```
**Rules:**
- Lifecycle hooks (`after_insert`, `on_update`, `before_save`, `on_trash`, `after_rename`, etc.) contain **no inline logic** — they only call `self.method()`.
- All logic lives in methods on the class, not in module-level helper functions.
- Methods that mutate a related document (e.g. an Assignment Rule) receive that doc as an argument and mutate it in memory. The **caller** is responsible for the final `doc.save()` — this keeps saves to a minimum (one save per hook).
- Never fetch the same document twice in one hook. Fetch once, pass the doc through the call chain.
- `@staticmethod` is allowed for pure transformations (e.g. building a condition string) that don't touch `self` or any Frappe doc.
**Save discipline:**
- Mutate all fields on a document before calling `.save()` once.
- Never call `.save()` inside a helper method — save in the hook or the method that owns the full mutation.
- Exception: if a method truly owns a complete document lifecycle (e.g. `create_assignment_rule` creates and fully initialises a new doc), it may save internally.
Take inspiration from "helpdesk.doctype.hd_team.hd_team.HDTeam" for examples of this pattern in practice.
## Conventions & Patterns
- **API Design:** Python modules in `helpdesk/api/` follow RESTful patterns. Extend via `helpdesk/extends/`.
@@ -339,4 +380,85 @@ const ticket = createDocumentResource({
---
## Agent Working Rules
These rules apply to all agents working in this codebase. Follow them without exception.
### 1. Site Data — Read Before You Write
When a Frappe site is referenced in the conversation (e.g. `erp-hd.localhost`, `hd-tests`),
treat it as the **source of truth for current data state**.
- Before assuming anything about existing records, custom fields, settings, or DocType state —
query the site first using the bench venv Python or `bench execute`.
- **Never write data to a site unless the user explicitly asks you to.**
Querying is always safe. Mutations (insert, update, delete, migrate, patch) require explicit
user instruction.
- If you need to verify a fix worked, re-query the site — do not assume success from code
inspection alone.
```python
# Safe pattern for querying a site
import sys, os
sys.path.insert(0, 'apps/frappe')
os.chdir('sites')
import frappe
frappe.init(site='<site-name>', sites_path='.')
frappe.connect()
# ... read-only queries here ...
frappe.destroy()
```
Run via: `cd /path/to/frappe-bench && env/bin/python /tmp/your_script.py`
---
### 2. Spec-Driven Development — Keep the Spec Current
When a feature has a spec document (e.g. `docs/erpnext-customer-sync.md`), the spec is the
**living source of truth** for that feature. It must be kept in sync with the actual
implementation at all times.
**Rules:**
- **Update the spec in the same session as the code change.** Never defer spec updates to a
later session.
- **The spec reflects what is implemented, not what was originally planned.** If a decision
changes mid-build (e.g. removing a scheduler job, changing how a field is set), update the
spec immediately after the code change.
- **Removed features must be removed from the spec.** Do not leave stale sections, blockers, or
file manifest entries for things that no longer exist.
- **New features must be added to the spec.** If a new API, component, or behaviour is
introduced, add a corresponding section.
- **Decisions and their rationale belong in the spec.** If something was done a specific way to
avoid a bug or framework limitation (e.g. not setting `customer_group` to avoid the group-node
error), document that in the relevant Blocker section.
- When in doubt about whether a spec update is needed — update it.
---
### 3. Test Utilities — Always Use `helpdesk/test_utils.py`
All backend test helpers must live in `helpdesk/test_utils.py` and be imported from there.
Never define helper functions (factories, enable/disable toggles, fixture builders) directly
inside individual test files.
**Rules:**
- **Before writing a helper in a test file**, check `helpdesk/test_utils.py` first — it may
already exist.
- **If a helper doesn't exist**, add it to `helpdesk/test_utils.py` and import it in the test
file. Do not leave it inline.
- **If you write helpers inline during development**, move them to `test_utils.py` before
finishing the task.
- Helper functions in `test_utils.py` must have a docstring explaining what they create or do. Only if the function is completely self-explanatory (e.g. `enable_erpnext_sync`) can the docstring be omitted.
- Naming conventions:
- Factories: `make_<doctype>(...)` — e.g. `make_hd_customer`, `make_ticket`, `make_team`
- Toggle helpers: `enable_<feature>()` / `disable_<feature>()` — e.g. `enable_erpnext_sync`
- Query helpers: descriptive verb — e.g. `get_latest_ticket_communication`
**For updates, merge new conventions here. If anything is unclear or missing, ask for clarification.**
**In the end your code will be reviewed by Codex, but following these rules will help ensure a smooth review process and a high-quality codebase.**
+1 -1
View File
@@ -18,7 +18,7 @@
"@vueuse/core": "^13.8.0",
"@vueuse/integrations": "^13.8.0",
"dayjs": "^1.11.7",
"frappe-ui": "0.1.267",
"frappe-ui": "0.1.278",
"gemoji": "^8.1.0",
"mime": "^3.0.0",
"pinia": "^2.0.33",
+8 -3
View File
@@ -9,7 +9,7 @@
import { Dialogs } from "@/components/dialogs";
import { useConfigStore } from "@/stores/config";
import { useFavicon } from "@vueuse/core";
import { FrappeUIProvider, setConfig, toast } from "frappe-ui";
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";
@@ -23,18 +23,23 @@ const { favicon } = storeToRefs(configStore);
useFavicon(favicon);
if (!localStorage.getItem("theme")) {
localStorage.setItem("theme", "light");
}
useTheme();
onMounted(() => {
window.addEventListener("online", () => {
toast.create({
message: __("You are now online."),
icon: h(Wifi, { class: "text-white" }),
icon: h(Wifi, { class: "text-ink-white" }),
});
});
window.addEventListener("offline", () => {
toast.create({
message: __("You are now offline."),
icon: h(WifiOff, { class: "text-white" }),
icon: h(WifiOff, { class: "text-ink-white" }),
});
});
!isCustomerPortal.value && setConfig("localTimezone", window.timezone?.user);
+3 -3
View File
@@ -9,7 +9,7 @@
<template #target="{ togglePopover }">
<button
:class="[
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-gray-800 hover:bg-gray-100',
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-8 hover:bg-surface-gray-2',
]"
@click.prevent="togglePopover()"
>
@@ -22,12 +22,12 @@
</template>
<template #body>
<div
class="flex flex-col justify-between mx-3 p-1.5 rounded-lg border border-gray-100 bg-white shadow-xl"
class="flex flex-col justify-between mx-3 p-1.5 rounded-lg border border-outline-gray-1 bg-surface-white shadow-xl"
>
<div v-for="app in apps.data" key="name">
<a
:href="app.route"
class="flex gap-2 rounded items-center hover:bg-gray-100 p-1.5"
class="flex gap-2 rounded items-center hover:bg-surface-gray-2 p-1.5"
>
<img class="size-6" :src="app.logo" />
<div class="text-sm" @click="app.onClick">
-201
View File
@@ -1,201 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
title: 'Assign To',
size: 'xl',
}"
>
<template #body-content>
<AutocompleteNew
v-if="showRestrictedMembers"
placeholder="Search agents"
:model-value="search"
:options="members"
@update:model-value="
({ _, value }) => {
addAssignee(value);
}
"
>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :name="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
{{ getUser(option.value).full_name }}
</Tooltip>
</template>
</AutocompleteNew>
<SearchComplete
v-else
class="form-control"
doctype="HD Agent"
:custom-filters="customFilters"
:reset-input="true"
@change="
(option) => {
addAssignee(option.value);
}
"
>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :name="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
{{ getUser(option.value).full_name }}
</Tooltip>
</template>
</SearchComplete>
<div class="mt-3 flex flex-wrap items-center gap-2">
<Tooltip
v-for="currentAssignee in assignees"
:key="currentAssignee.name"
:text="currentAssignee.name"
>
<Button
:label="getUser(currentAssignee.name).full_name"
theme="gray"
variant="outline"
>
<template #prefix>
<UserAvatar :name="currentAssignee.name" size="sm" />
</template>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeCurrentAssignee(currentAssignee.name)"
/>
</template>
</Button>
</Tooltip>
</div>
<ErrorMessage v-if="error" class="mt-2" :message="error" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import { AutocompleteNew, SearchComplete, UserAvatar } from "@/components";
import { useAuthStore } from "@/stores/auth";
import { useConfigStore } from "@/stores/config";
import { useUserStore } from "@/stores/user";
import { call, createResource } from "frappe-ui";
import { useOnboarding } from "frappe-ui/frappe";
import { computed, onMounted, ref } from "vue";
const props = defineProps({
doctype: {
type: String,
required: true,
},
docname: {
type: String,
required: true,
},
assignees: {
type: Array,
required: true,
},
team: {
type: String,
default: "",
},
});
const show = defineModel();
const emit = defineEmits(["update"]);
const { getUser } = useUserStore();
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { isManager } = useAuthStore();
const { teamRestrictionApplied, assignWithinTeam } = useConfigStore();
const error = ref("");
const addAssignee = (value) => {
error.value = "";
createResource({
url: "frappe.desk.form.assign_to.add",
auto: true,
params: {
doctype: props.doctype,
name: props.docname,
assign_to: [value],
},
onSuccess: () => {
emit("update");
if (isManager) {
updateOnboardingStep("assign_to_agent");
}
members.value = members.value.filter((m) => m.value !== value);
},
});
emit("update");
};
const removeCurrentAssignee = (value) => {
createResource({
url: "frappe.desk.form.assign_to.remove",
auto: true,
params: {
doctype: props.doctype,
name: props.docname,
assign_to: value,
},
onSuccess: () => {
emit("update");
members.value.push({
label: value,
value: value,
});
},
});
};
const customFilters = computed(() => {
const filters = {};
filters["is_active"] = ["=", 1];
if (Boolean(props.assignees?.length)) {
filters["name"] = ["not in", [...props.assignees.map((a) => a.name)]];
}
return filters;
});
const search = ref("");
const members = ref([]);
async function getMembers() {
let teamMembers = await call(
"helpdesk.helpdesk.doctype.hd_team.hd_team.get_team_members",
{
team: props.team,
}
);
let assignedMembers = props.assignees.map((a) => a.name);
teamMembers = teamMembers.filter((member: string) => {
return !assignedMembers.includes(member);
});
members.value = teamMembers.map((member: string) => {
return {
label: member,
value: member,
};
});
}
const showRestrictedMembers = computed(() => {
return teamRestrictionApplied && assignWithinTeam && props.team;
});
onMounted(() => {
if (showRestrictedMembers.value) {
getMembers();
}
});
</script>
+16 -12
View File
@@ -21,13 +21,13 @@
>
{{ displayValue(selectedValue) }}
</span>
<span v-else class="text-base leading-5 text-gray-600">
<span v-else class="text-base leading-5 text-ink-gray-4">
{{ placeholder || "" }}
</span>
</div>
<FeatherIcon
name="chevron-down"
class="h-4 w-4 text-gray-600"
class="h-4 w-4 text-ink-gray-5"
aria-hidden="true"
/>
</button>
@@ -36,11 +36,13 @@
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base shadow-2xl"
>
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full pr-6"
class="form-input w-full pr-6 bg-transparent"
type="text"
:value="query"
autocomplete="off"
@@ -70,7 +72,7 @@
>
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-gray-500"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
>
{{ group.group }}
</div>
@@ -84,7 +86,7 @@
<li
:class="[
'flex items-center rounded px-2.5 py-1.5 text-base',
{ 'bg-gray-100': active },
{ 'bg-surface-gray-2': active },
]"
>
<slot
@@ -102,7 +104,7 @@
</div>
<li
v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-gray-600"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
>
No results found
</li>
@@ -241,7 +243,7 @@ watch(showOptions, (val) => {
});
const textColor = computed(() => {
return props.disabled ? "text-gray-600" : "text-gray-800";
return props.disabled ? "text-ink-gray-5" : "text-ink-gray-8";
});
const inputClasses = computed(() => {
@@ -262,12 +264,14 @@ const inputClasses = computed(() => {
let variant = props.disabled ? "disabled" : props.variant;
let variantClasses = {
subtle:
"border border-gray-100 bg-gray-100 placeholder-gray-500 hover:border-gray-200 hover:bg-gray-200 focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400",
"border border-outline-gray-1 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3",
outline:
"border border-gray-300 bg-white placeholder-gray-500 hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400",
"border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3",
disabled: [
"border bg-gray-50 placeholder-gray-400",
props.variant === "outline" ? "border-gray-300" : "border-transparent",
"border bg-surface-menu-bar placeholder-ink-gray-3",
props.variant === "outline"
? "border-outline-gray-2"
: "border-transparent",
],
}[variant];
+9 -7
View File
@@ -32,20 +32,22 @@
vs {{ currentDuration.toLowerCase() }}
<FeatherIcon name="chevron-down" class="size-4" />
</div>
<template #item="{ item }">
<template #item-label="{ item }">
<div
class="data-[disabled]:cursor-not-allowed group flex h-7 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"
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>
<FeatherIcon
v-if="item.label == __(currentDuration)"
name="check"
class="size-4"
/>
</div>
</template>
<template #item-suffix="{ item }">
<FeatherIcon
v-if="item.label == __(currentDuration)"
name="check"
class="size-4"
/>
</template>
</Dropdown>
</div>
</div>
+6 -6
View File
@@ -1,14 +1,14 @@
<template>
<div class="flex-col text-base flex-1" ref="commentBoxRef">
<div class="mb-2 flex items-center justify-between">
<div class="text-gray-600 flex items-center gap-2">
<div class="text-ink-gray-5 flex items-center gap-2">
<Avatar
size="md"
:label="commenter"
:image="getUser(commentedBy).user_image"
/>
<p>
<span class="font-medium text-gray-800">
<span class="font-medium text-ink-gray-8">
{{ commenter }}
</span>
<span> {{ __(" commented") }}</span>
@@ -16,7 +16,7 @@
</div>
<div class="flex items-center gap-1">
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
<span class="pl-0.5 text-sm text-gray-600">
<span class="pl-0.5 text-sm text-ink-gray-5">
{{ timeAgo(creation) }}
</span>
</Tooltip>
@@ -28,7 +28,7 @@
>
<Button
icon="more-horizontal"
class="text-gray-600"
class="text-ink-gray-5"
variant="ghost"
/>
</Dropdown>
@@ -129,7 +129,7 @@
class="flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors"
:class="
reaction.current_user_reacted
? 'bg-blue-100 text-blue-700 hover:bg-blue-200'
? 'bg-surface-blue-2 text-ink-blue-3 hover:bg-surface-blue-3'
: 'bg-surface-gray-3 text-ink-gray-6 hover:bg-surface-gray-4'
"
v-if="reaction.count !== 0"
@@ -305,7 +305,7 @@ const deleteComment = createResource({
}),
onSuccess() {
emit("update");
toast.success(__("Comment deleted sucessfully."));
toast.success(__("Comment deleted successfully."));
},
});
+12 -2
View File
@@ -14,7 +14,7 @@
:editable="editable"
:mentions="dropdown"
@change="editable ? (newComment = $event) : null"
:extensions="[ComponentUtils, HandleExcelPaste]"
:extensions="[ComponentUtils, HandleExcelPaste, CleanStyles]"
:uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)"
>
<template #bottom>
@@ -113,7 +113,11 @@ import { AttachmentIcon } from "@/components/icons/";
import { useTyping } from "@/composables/realtime";
import { useAgentStore } from "@/stores/agent";
import { useAuthStore } from "@/stores/auth";
import { ComponentUtils, HandleExcelPaste } from "@/tiptap-extensions";
import {
CleanStyles,
ComponentUtils,
HandleExcelPaste,
} from "@/tiptap-extensions";
import {
getFontFamily,
isContentEmpty,
@@ -230,6 +234,12 @@ onMounted(() => {
agentsList.value.fetch();
});
onBeforeUnmount(() => {
if (isContentEmpty(newComment.value)) {
localStorage.removeItem("commentBoxContent" + props.ticketId);
}
});
defineExpose({
submitComment,
editor,
+98 -69
View File
@@ -8,7 +8,9 @@
ref="sendEmailRef"
variant="ghost"
label="Reply"
:class="[showEmailBox ? '!bg-gray-300 hover:!bg-gray-200' : '']"
:class="[
showEmailBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
]"
@click="toggleEmailBox()"
>
<template #prefix>
@@ -18,7 +20,9 @@
<Button
variant="ghost"
label="Comment"
:class="[showCommentBox ? '!bg-gray-300 hover:!bg-gray-200' : '']"
:class="[
showCommentBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
]"
@click="toggleCommentBox()"
>
<template #prefix>
@@ -28,71 +32,77 @@
<TypingIndicator :ticketId="ticketId" />
</div>
</div>
<div
ref="emailBoxRef"
v-show="showEmailBox"
class="flex gap-1.5 flex-1"
@keydown.ctrl.enter.capture.stop="submitEmail"
@keydown.meta.enter.capture.stop="submitEmail"
@keydown.esc.capture.stop="showEmailBox = false"
>
<EmailEditor
ref="emailEditorRef"
:label="
isMobileView ? 'Send' : isMac ? 'Send (⌘ + ⏎)' : 'Send (Ctrl + ⏎)'
"
v-model:content="content"
placeholder="Hi John, we are looking into this issue."
:ticketId="ticketId"
:to-emails="toEmails"
:cc-emails="ccEmails"
:bcc-emails="bccEmails"
@submit="
() => {
showEmailBox = false;
emit('update');
}
"
@discard="
() => {
showEmailBox = false;
}
"
/>
</div>
<div
ref="commentBoxRef"
v-show="showCommentBox"
@keydown.ctrl.enter.capture.stop="submitComment"
@keydown.meta.enter.capture.stop="submitComment"
@keydown.esc.capture.stop="showCommentBox = false"
>
<CommentTextEditor
ref="commentTextEditorRef"
:label="
isMobileView
? 'Comment'
: isMac
? 'Comment (⌘ + ⏎)'
: 'Comment (Ctrl + ⏎)'
"
:ticketId="ticketId"
:editable="showCommentBox"
:doctype="doctype"
placeholder="@John could you please look into this?"
@submit="
() => {
showCommentBox = false;
emit('update');
}
"
@discard="
() => {
showCommentBox = false;
}
"
/>
</div>
<Transition name="slide">
<div
v-show="showEmailBox"
ref="emailBoxRef"
@keydown.ctrl.enter.capture.stop="submitEmail"
@keydown.meta.enter.capture.stop="submitEmail"
@keydown.esc.capture.stop="showEmailBox = false"
>
<div class="overflow-hidden">
<EmailEditor
ref="emailEditorRef"
:label="
isMobileView ? 'Send' : isMac ? 'Send (⌘ + ⏎)' : 'Send (Ctrl + ⏎)'
"
placeholder="Hi John, we are looking into this issue."
:ticketId="ticketId"
:to-emails="toEmails"
:cc-emails="ccEmails"
:bcc-emails="bccEmails"
@submit="
() => {
showEmailBox = false;
emit('update');
}
"
@discard="
() => {
showEmailBox = false;
}
"
/>
</div>
</div>
</Transition>
<Transition name="slide">
<div
v-show="showCommentBox"
ref="commentBoxRef"
@keydown.ctrl.enter.capture.stop="submitComment"
@keydown.meta.enter.capture.stop="submitComment"
@keydown.esc.capture.stop="showCommentBox = false"
>
<div class="overflow-hidden">
<CommentTextEditor
ref="commentTextEditorRef"
:label="
isMobileView
? 'Comment'
: isMac
? 'Comment (⌘ + ⏎)'
: 'Comment (Ctrl + ⏎)'
"
:ticketId="ticketId"
:editable="showCommentBox"
:doctype="doctype"
placeholder="@John could you please look into this?"
@submit="
() => {
showCommentBox = false;
emit('update');
}
"
@discard="
() => {
showCommentBox = false;
}
"
/>
</div>
</div>
</Transition>
</div>
</template>
@@ -188,7 +198,7 @@ watch(
() => showEmailBox.value,
(value) => {
if (value) {
emailEditorRef.value?.editor?.commands?.focus();
emailEditorRef.value?.editor?.commands?.focus("start");
}
}
);
@@ -224,7 +234,12 @@ onClickOutside(
}
},
{
ignore: [".tippy-box", ".tippy-content", ".PopoverContent"],
ignore: [
".tippy-box",
".tippy-content",
".PopoverContent",
'[role="dialog"]',
],
}
);
@@ -247,4 +262,18 @@ onClickOutside(
width: 100vw;
}
}
.slide-enter-active,
.slide-leave-active {
display: grid;
transition: grid-template-rows 0.25s ease;
}
.slide-enter-from,
.slide-leave-to {
grid-template-rows: 0fr;
}
.slide-enter-to,
.slide-leave-from {
grid-template-rows: 1fr;
}
</style>
+5 -1
View File
@@ -2,7 +2,11 @@
<Dialog v-model="showDialog" :options="{ title }" @close="closeDialog">
<template #body-content>
<div class="space-y-4">
<p class="text-p-base text-gray-800" v-if="message" v-html="message" />
<p
class="text-p-base text-ink-gray-8"
v-if="message"
v-html="message"
/>
</div>
</template>
<template #actions>
+52 -44
View File
@@ -2,7 +2,7 @@
<div
:id="`communication-${name}`"
v-bind="$attrs"
class="grow cursor-pointer bg-white rounded-md text-base leading-6 transition-all duration-300 ease-in-out border border-outline-gray-2"
class="grow cursor-pointer bg-surface-white rounded-md text-base leading-6 transition-all duration-300 ease-in-out border border-outline-gray-2"
>
<div
class="flex items-center justify-between gap-2"
@@ -35,7 +35,7 @@
<div class="flex gap-2 items-center">
<div class="gap-0.5 flex items-center">
<Badge
v-if="status.label && !ticket.doc.via_customer_portal"
v-if="status.label && !ticket?.doc?.via_customer_portal"
:label="__(status.label)"
variant="subtle"
:theme="status.color"
@@ -50,12 +50,16 @@
</p>
</Tooltip>
</div>
<div class="flex items-center">
<Button variant="ghost" @click="reply">
<ReplyIcon class="h-4 w-4 text-ink-gray-5" />
<div class="flex items-center gap-1">
<Button :tooltip="__('Reply')" variant="ghost" @click="reply">
<template #icon>
<ReplyIcon class="text-ink-gray-7" />
</template>
</Button>
<Button variant="ghost" @click="replyAll">
<ReplyAllIcon class="h-4 w-6 text-ink-gray-5" />
<Button :tooltip="__('Reply All')" variant="ghost" @click="replyAll">
<template #icon>
<ReplyAllIcon class="text-ink-gray-7" />
</template>
</Button>
<Dropdown
v-if="showSplitOption"
@@ -70,7 +74,7 @@
>
<Button
icon="more-horizontal"
class="text-ink-gray-5 ml-0.5"
class="!text-ink-gray-7"
variant="ghost"
/>
</Dropdown>
@@ -80,15 +84,16 @@
<!-- <div class="text-sm leading-5 text-ink-gray-5">
{{ subject }}
</div> -->
<div class="text-sm leading-5 text-ink-gray-5">
<span v-if="to" class="mr-1">To:</span>
<span v-if="to"> {{ to }} </span>
<span v-if="cc">, </span>
<span v-if="cc"> Cc: </span>
<span v-if="cc">{{ cc }}</span>
<span v-if="bcc">, </span>
<span v-if="bcc"> Bcc: </span>
<span v-if="bcc">{{ bcc }}</span>
<div class="text-p-sm text-ink-gray-5">
<template
v-for="(val, label) in { To: to, cc: cc, bcc: bcc }"
:key="label"
>
<span v-if="val" class="mr-1.5">
<span class="mr-1 text-ink-gray-7">{{ label }}:</span>
<span> {{ normalizeAndFilter(val).join(", ") }}</span>
</span>
</template>
</div>
<div class="border-0 border-t my-3 border-outline-gray-modals !-mx-3" />
<EmailContent :content="content" />
@@ -169,6 +174,32 @@ const status = computed(() => {
return { label: _status, color: indicator_color };
});
const normalizeAndFilter = (
field: string | string[],
valuesToExclude: string[] = []
) => {
let arr = [];
let current = "";
let inQuotes = false;
if (typeof field === "string") {
for (let char of field) {
if (char === '"') {
inQuotes = !inQuotes;
current += char;
} else if (char === "," && !inQuotes) {
arr.push(current.trim());
current = "";
} else {
current += char;
}
}
if (current) arr.push(current.trim());
} else {
arr = field || [];
}
return arr.filter(Boolean).filter((item) => !valuesToExclude.includes(item));
};
const reply = () => {
const user = auth.user.value;
emit("reply", {
@@ -179,33 +210,10 @@ const reply = () => {
const replyAll = () => {
const user = auth.user.value;
const normalizeAndFilter = (field) => {
let arr = [];
let current = "";
let inQuotes = false;
if (typeof field === "string") {
for (let char of field) {
if (char === '"') {
inQuotes = !inQuotes;
current += char;
} else if (char === "," && !inQuotes) {
arr.push(current.trim());
current = "";
} else {
current += char;
}
}
if (current) arr.push(current.trim());
} else {
arr = field || [];
}
return arr.filter((item) => item !== user && item !== sender.name);
};
const filteredTo = normalizeAndFilter(to);
const filteredCc = normalizeAndFilter(cc);
const filteredBcc = normalizeAndFilter(bcc);
const exclude = [user, sender.name];
const filteredTo = normalizeAndFilter(to, exclude);
const filteredCc = normalizeAndFilter(cc, exclude);
const filteredBcc = normalizeAndFilter(bcc, exclude);
let _to, _cc, _bcc;
+29 -4
View File
@@ -7,7 +7,7 @@
</template>
<script setup lang="ts">
import { getFontFamily } from "@/utils";
import { dataTheme, getFontFamily, stripEmailColors } from "@/utils";
import { computed, ref, watch } from "vue";
const props = defineProps({
@@ -18,7 +18,7 @@ const props = defineProps({
});
const iframeRef = ref<HTMLIFrameElement | null>(null);
const _content = ref(props.content);
const _content = ref(stripEmailColors(props.content));
// Get CSS path - in dev Vite serves it directly, in prod we need the built path
const cssHref = computed(() => {
@@ -178,6 +178,12 @@ const htmlContent = computed(
.email-content :where(img):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
margin: 0;
}
.email-content :where(blockquote p:first-of-type):not(:where([class~='not-prose'], [class~='not-prose'] *))::before {
content: none;
}
.email-content :where(blockquote p:last-of-type):not(:where([class~='not-prose'], [class~='not-prose'] *))::after {
content: none;
}
</style>
</head>
@@ -197,8 +203,7 @@ watch(iframeRef, (iframe) => {
const parent = emailContent.closest("html");
if (!parent) return;
let theme = document.documentElement.getAttribute("data-theme");
parent.setAttribute("data-theme", theme);
parent.setAttribute("data-theme", dataTheme.value);
const font = getFontFamily(_content.value);
if (font) emailContent.classList.add(font);
@@ -212,6 +217,21 @@ watch(iframeRef, (iframe) => {
);
});
// This is to ensure that keyboard shortcuts work even when the iframe is focused. For example, pressing "r" to reply to an email should work even if the user has clicked inside the email content.
iframe.contentDocument?.addEventListener("keydown", (e) => {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: e.key,
code: e.code,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
shiftKey: e.shiftKey,
altKey: e.altKey,
bubbles: true,
})
);
});
const replyCollapsers = emailContent.querySelectorAll(".replyCollapser");
if (replyCollapsers.length) {
replyCollapsers.forEach((replyCollapser) => {
@@ -223,4 +243,9 @@ watch(iframeRef, (iframe) => {
};
}
});
watch(dataTheme, (theme) => {
const html = iframeRef.value?.contentDocument?.documentElement;
if (html) html.setAttribute("data-theme", theme);
});
</script>
+241 -131
View File
@@ -11,13 +11,27 @@
:placeholder="placeholder"
:editable="editable"
@change="editable ? (newEmail = $event) : null"
:extensions="[ComponentUtils, HandleExcelPaste]"
:extensions="[ComponentUtils, HandleExcelPaste, CleanStyles]"
:uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)"
@keydown.capture="handleKeydown"
>
<template #top>
<div
v-if="hasMultipleSenders"
class="mx-6 md:mx-5 flex items-center gap-2 border-t py-2.5 h-12.5"
>
<span class="text-p-xs text-ink-gray-4">{{ __("From") }}:</span>
<FormControl
v-model="fromEmail"
type="select"
variant="ghost"
class="w-full"
:placeholder="__('')"
:options="from"
/>
</div>
<div class="mx-6 md:mx-5 flex items-center gap-2 border-y py-2.5">
<span class="text-xs text-gray-500">TO:</span>
<span class="text-p-xs text-ink-gray-4">{{ __("To") }}:</span>
<MultiSelectInput
v-model="toEmailsClone"
class="flex-1"
@@ -27,7 +41,7 @@
/>
<div class="flex gap-1.5">
<Button
:label="'CC'"
:label="__('Cc')"
variant="ghost"
:class="[
cc || showCC
@@ -37,7 +51,7 @@
@click="toggleCC()"
/>
<Button
:label="'BCC'"
:label="__('Bcc')"
variant="ghost"
:class="[
bcc || showBCC
@@ -53,7 +67,7 @@
class="mx-5 flex items-center gap-2 py-2.5"
:class="cc || showCC ? 'border-b' : ''"
>
<span class="text-xs text-gray-500">CC:</span>
<span class="text-xs text-ink-gray-4">{{ __("Cc:") }}</span>
<MultiSelectInput
ref="ccInput"
v-model="ccEmailsClone"
@@ -68,7 +82,7 @@
class="mx-5 flex items-center gap-2 py-2.5"
:class="bcc || showBCC ? 'border-b' : ''"
>
<span class="text-xs text-gray-500">BCC:</span>
<span class="text-xs text-ink-gray-4">{{ __("Bcc:") }}</span>
<MultiSelectInput
ref="bccInput"
v-model="bccEmailsClone"
@@ -81,15 +95,28 @@
</template>
<template #editor>
<div class="overflow-y-auto min-h-[7rem] max-h-[30vh]">
<EditorContent :editor="editor" />
<div class="overflow-y-auto min-h-[7rem] max-h-[30vh] flex flex-col">
<div class="flex-1">
<EditorContent :editor="editor" />
</div>
<div
v-if="quotedContent"
ref="quotedContentRef"
contenteditable="true"
class="prose !max-w-full mx-6 md:mx-5 my-2 border-l-4 border-gray-300 pl-4 text-sm focus:outline-none"
@input="onQuotedInput"
/>
class="replied-content mx-6 md:mx-5 mb-2 mt-auto"
>
<label class="collapse" for="quoted-toggle">...</label>
<input
id="quoted-toggle"
class="replyCollapser"
type="checkbox"
:checked="isQuoteExpanded"
/>
<div
ref="quotedContentRef"
contenteditable="true"
class="prose !max-w-full mx-1 my-2 border-l-4 border-outline-gray-2 pl-4 text-sm focus:outline-none"
@input="onQuotedInput"
/>
</div>
</div>
</template>
<template #bottom>
@@ -170,7 +197,6 @@
</template>
</TextEditor>
<SavedRepliesSelectorModal
v-if="showSavedRepliesSelectorModal"
v-model="showSavedRepliesSelectorModal"
:doctype="doctype"
@apply="applySavedReplies"
@@ -187,9 +213,14 @@ import {
import { AttachmentIcon } from "@/components/icons";
import { useTyping } from "@/composables/realtime";
import { useAuthStore } from "@/stores/auth";
import { ComponentUtils, HandleExcelPaste } from "@/tiptap-extensions";
import {
CleanStyles,
ComponentUtils,
HandleExcelPaste,
} from "@/tiptap-extensions";
import {
getFontFamily,
htmlToText,
isContentEmpty,
removeAttachmentFromServer,
textEditorMenuButtons,
@@ -216,10 +247,7 @@ import {
} from "vue";
import SavedReplyIcon from "./icons/SavedReplyIcon.vue";
const editorRef = ref(null);
const showSavedRepliesSelectorModal = ref(false);
const quotedContentRef = ref<HTMLElement | null>(null);
// ─── Props & Emits ────────────────────────────────────────────
const props = defineProps({
ticketId: {
type: String,
@@ -255,49 +283,95 @@ const props = defineProps({
},
});
const label = computed(() => {
return sendMail.loading ? "Sending..." : props.label;
});
const emit = defineEmits(["submit", "discard"]);
const newEmail = useStorage<null | string>(
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { isManager } = useAuthStore();
const { onUserType, cleanup } = useTyping(props.ticketId);
const editorRef = ref(null);
const editor = computed(() => editorRef.value.editor);
function focusEditorAtStart() {
setTimeout(() => {
editorRef.value?.editor?.commands?.focus("start");
}, 0);
}
const cachedEmail = useStorage<null | string>(
"emailBoxContent" + props.ticketId,
null
);
const newEmail = ref<null | string>(cachedEmail.value);
const emailSignature = ref<string | null>(null);
function isOnlySignature(content: string | null) {
if (!content || !emailSignature.value) return false;
return htmlToText(content) === htmlToText(emailSignature.value);
}
const userResource = createResource({
url: "helpdesk.api.auth.get_current_user_email_info",
cache: "current-user-email-info",
auto: true,
});
watch(newEmail, (newValue, oldValue) => {
if (newValue !== oldValue && newValue) {
onUserType();
}
cachedEmail.value = isOnlySignature(newValue) ? null : newValue;
});
const quotedContent = useStorage<null | string>(
"quotedEmailBoxContent" + props.ticketId,
null
);
const quotedContentRef = ref<HTMLElement | null>(null);
const isQuoteExpanded = ref(false);
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { isManager } = useAuthStore();
function onQuotedInput() {
const el = quotedContentRef.value;
if (!el) return;
quotedContent.value = el.innerHTML || null;
}
// Initialize typing composable
const { onUserType, cleanup } = useTyping(props.ticketId);
const attachments = ref([]);
const isUploading = ref(false);
const contentEmpty = computed(() => isContentEmpty(newEmail.value));
const isDisabled = computed(() => {
return (
(isContentEmpty(newEmail.value) && isContentEmpty(quotedContent.value)) ||
sendMail.loading ||
isUploading.value
);
});
// Watch for changes in email content to trigger typing events
watch(newEmail, (newValue, oldValue) => {
if (newValue !== oldValue && newValue) {
onUserType();
watch(quotedContent, (newVal, oldVal) => {
if (!oldVal && newVal) {
nextTick(() => {
if (quotedContentRef.value) {
quotedContentRef.value.innerHTML = newVal;
}
});
}
});
onBeforeUnmount(() => {
cleanup();
watch(
() => userResource.data,
(data: { email_signature?: string } | null) => {
if (!data?.email_signature) return;
emailSignature.value = `<br>${data.email_signature}`;
if (isOnlySignature(cachedEmail.value)) {
cachedEmail.value = null;
}
if (isContentEmpty(newEmail.value) && !quotedContent.value) {
newEmail.value = emailSignature.value;
focusEditorAtStart();
}
},
{ immediate: true }
);
onMounted(() => {
if (quotedContent.value) {
nextTick(() => {
if (quotedContentRef.value) {
quotedContentRef.value.innerHTML = quotedContent.value;
}
});
}
});
const toEmailsClone = ref([...props.toEmails]);
@@ -310,74 +384,8 @@ const bcc = computed(() => (bccEmailsClone.value?.length ? true : false));
const ccInput = ref(null);
const bccInput = ref(null);
function applySavedReplies(template: string) {
isContentEmpty(newEmail.value)
? (newEmail.value = template)
: (newEmail.value = newEmail.value + "\n" + template);
showSavedRepliesSelectorModal.value = false;
}
const sendMail = createResource({
url: "run_doc_method",
makeParams: () => ({
dt: props.doctype,
dn: props.ticketId,
method: "reply_via_agent",
args: {
attachments: attachments.value.map((x) => x.name),
to: toEmailsClone.value.join(","),
cc: ccEmailsClone.value?.join(","),
bcc: bccEmailsClone.value?.join(","),
message:
newEmail.value +
(quotedContentRef.value
? `<p class="reply-to-content"><p><blockquote>${quotedContentRef.value.innerHTML}</blockquote>`
: ""),
},
}),
onSuccess: () => {
resetState();
emit("submit");
if (isManager) {
updateOnboardingStep("reply_on_ticket");
}
},
debounce: 300,
});
function submitMail() {
if (isContentEmpty(newEmail.value) && isContentEmpty(quotedContent.value)) {
return false;
}
if (!toEmailsClone.value.length) {
toast.warning(
"Email has no recipients. Please add at least one email address in the 'TO' field."
);
return false;
}
sendMail.submit();
}
watch(quotedContent, (newVal, oldVal) => {
if (!oldVal && newVal) {
nextTick(() => {
if (quotedContentRef.value) {
quotedContentRef.value.innerHTML = newVal;
}
});
}
});
function onQuotedInput() {
const el = quotedContentRef.value;
if (!el) return;
quotedContent.value = el.innerHTML || null;
}
function toggleCC() {
showCC.value = !showCC.value;
showCC.value &&
nextTick(() => {
ccInput.value.setFocus();
@@ -392,11 +400,119 @@ function toggleBCC() {
});
}
const fromEmail = useStorage<string | "">("from-email", "");
const outgoingEmails = computed<{ email_account: string; email_id: string }[]>(
() => userResource.data?.outgoing_emails ?? []
);
// selected mail from the outgoing emails list
const selectedFromEmail = computed(() =>
outgoingEmails.value.find((e) => e.email_id === fromEmail.value)
);
const from = computed(() => {
if (!outgoingEmails.value.length) return [];
if (
outgoingEmails.value.length === 1 &&
outgoingEmails.value[0].email_id === userResource.data?.email
)
return [];
return outgoingEmails.value.map((e) => ({
label: e.email_account + " <" + e.email_id + ">",
value: e.email_id,
}));
});
const hasMultipleSenders = computed(() => (from?.value.length ?? 0) > 1);
watch(
from,
(fromOptions) => {
if (!fromOptions.find((f) => f.value === fromEmail.value)) {
fromEmail.value = fromOptions.length ? fromOptions[0].value : "";
}
},
{ immediate: true }
);
const attachments = ref([]);
const isUploading = ref(false);
async function removeAttachment(attachment) {
attachments.value = attachments.value.filter((a) => a !== attachment);
await removeAttachmentFromServer(attachment.name);
}
const showSavedRepliesSelectorModal = ref(false);
function applySavedReplies(template: string) {
const textEditor = editorRef.value?.editor;
if (!textEditor) return;
textEditor.chain().focus("start").insertContent(template).run();
}
const sendMail = createResource({
url: "run_doc_method",
makeParams: () => ({
dt: props.doctype,
dn: props.ticketId,
method: "reply_via_agent",
args: {
attachments: attachments.value.map((x) => x.name),
from_email: selectedFromEmail.value,
to: toEmailsClone.value.join(","),
cc: ccEmailsClone.value?.join(","),
bcc: bccEmailsClone.value?.join(","),
message:
newEmail.value +
(quotedContentRef.value
? `<p class="reply-to-content"></p><blockquote>${quotedContentRef.value.innerHTML}</blockquote>`
: ""),
},
}),
onSuccess: () => {
resetState();
emit("submit");
if (isManager) {
updateOnboardingStep("reply_on_ticket");
}
},
debounce: 300,
});
const label = computed(() => (sendMail.loading ? "Sending..." : props.label));
const isDisabled = computed(
() =>
(isContentEmpty(newEmail.value) && isContentEmpty(quotedContent.value)) ||
sendMail.loading ||
isUploading.value
);
function submitMail() {
if (isContentEmpty(newEmail.value) && isContentEmpty(quotedContent.value)) {
return false;
}
if (
!toEmailsClone.value.length &&
!ccEmailsClone.value.length &&
!bccEmailsClone.value.length
) {
toast.warning(
"Email has no recipients. Please add at least one recipient (To, Cc, or Bcc) before sending."
);
return false;
}
sendMail.submit();
}
function getInitialContent() {
return emailSignature.value ? emailSignature.value : "<p></p>";
}
function addToReply(
body: string,
toEmails: string[],
@@ -410,46 +526,40 @@ function addToReply(
if (body !== quotedContent.value) {
//trigger change for watch when replied to body data is different from current quoted content
quotedContent.value = null;
isQuoteExpanded.value = false;
nextTick(() => {
quotedContent.value = body;
});
}
editorRef.value.editor.chain().clearContent().focus("start").run();
nextTick(() => {
newEmail.value = editorRef.value.editor.getHTML();
newEmail.value = getInitialContent();
});
focusEditorAtStart();
}
function resetState() {
newEmail.value = null;
newEmail.value = emailSignature.value ? emailSignature.value : null;
attachments.value = [];
quotedContent.value = null;
isQuoteExpanded.value = false;
focusEditorAtStart();
}
function handleDiscard() {
attachments.value = [];
newEmail.value = null;
newEmail.value = getInitialContent();
quotedContent.value = null;
ccEmailsClone.value = [];
bccEmailsClone.value = [];
showCC.value = false;
showBCC.value = false;
isQuoteExpanded.value = false;
focusEditorAtStart();
emit("discard");
}
//on load set quoted content from storage
onMounted(() => {
if (quotedContent.value) {
nextTick(() => {
if (quotedContentRef.value) {
quotedContentRef.value.innerHTML = quotedContent.value;
}
});
}
});
function handleSelectAll(e: KeyboardEvent) {
const active = document.activeElement;
const editorContext = editorRef.value?.editor;
@@ -484,7 +594,6 @@ function handleDelete(e: KeyboardEvent) {
if (!sel || sel.isCollapsed || !quotedEl || !editorDom) return;
const isSelectingEntireEditor = sel.containsNode(editorDom, true);
const isSelectingEntireQuote = sel.containsNode(quotedEl, true);
if (isSelectingEntireEditor && isSelectingEntireQuote) {
@@ -502,6 +611,7 @@ function handleKeydown(e: KeyboardEvent) {
const key = e.key.toLowerCase();
if ((e.metaKey || e.ctrlKey) && key === "a") {
isQuoteExpanded.value = true;
handleSelectAll(e);
return;
}
@@ -512,8 +622,8 @@ function handleKeydown(e: KeyboardEvent) {
}
}
const editor = computed(() => {
return editorRef.value.editor;
onBeforeUnmount(() => {
cleanup();
});
defineExpose({
+54 -5
View File
@@ -1,16 +1,61 @@
<template>
<div class="flex h-full items-center justify-center w-[stretch]">
<div
v-if="variant === 'badge'"
class="flex flex-col items-center justify-center gap-4 h-[stretch] absolute w-[stretch] left-0 top-5.5 pointer-events-none"
>
<div
class="flex flex-col items-center gap-3 text-xl font-medium text-ink-gray-4 w-9/12 md:w-4/12"
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<component v-if="icon" :is="icon" class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __(title) }}
</div>
<div
v-if="descriptionText"
class="text-p-sm text-ink-gray-5 max-w-60 text-center"
>
{{ __(descriptionText) }}
</div>
</div>
</div>
<div
v-else
class="flex h-full items-center justify-center w-[stretch] absolute top-0 pointer-events-none"
>
<div
class="flex flex-col items-center gap-2 text-xl font-medium text-ink-gray-4 w-9/12 md:w-4/12"
>
<!-- overlay variant (for charts) -->
<div
v-if="variant === 'overlay'"
class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none -z-10"
:style="{
backgroundImage:
'radial-gradient(ellipse at center, var(--surface-white) 10%, color-mix(in srgb, var(--surface-white) 90%, transparent) 25%, transparent 70%)',
}"
/>
<!-- Icon -->
<component v-if="icon" :is="icon" class="h-10 w-10" />
<!-- title -->
<div class="flex flex-col items-center justify-center">
<span class="text-lg font-medium text-ink-gray-8">{{ __(title) }}</span>
<div class="flex flex-col items-center justify-center gap-0.5">
<span
:class="{
'text-sm font-medium text-ink-gray-8': text === 'sm',
'text-base font-medium text-ink-gray-8': text === 'md' || !text,
'text-lg font-medium text-ink-gray-8': text === 'lg',
}"
>
{{ __(title) }}
</span>
<span
v-if="descriptionText"
class="text-center text-p-base text-ink-gray-6 mt-1"
:class="{
'text-center text-xs text-ink-gray-6 mt-1': text === 'sm',
'text-center text-sm text-ink-gray-6 mt-1': text === 'md' || !text,
'text-center text-base text-ink-gray-6 mt-1': text === 'lg',
}"
>
{{ __(descriptionText) }}
</span>
@@ -25,11 +70,15 @@ interface Props {
title: string;
icon?: VNode | string;
description?: string;
variant?: "default" | "overlay" | "badge";
text?: "sm" | "md" | "lg";
}
const props = withDefaults(defineProps<Props>(), {
title: "No Data Found",
icon: "",
variant: "default",
text: "lg",
});
const descriptionText = computed(() =>
+9 -8
View File
@@ -22,14 +22,14 @@
</template>
</Button>
</div>
<div class="text-gray-600 w-4/6 text-p-base" v-else>
<span class="font-medium text-gray-800">
<div class="text-ink-gray-5 w-4/6 text-p-base" v-else>
<span class="font-medium text-ink-gray-8">
{{ user }}
</span>
<span> {{ content }}</span>
</div>
<div class="text-gray-600 text-sm w-2/6 flex justify-end">
<div class="text-ink-gray-5 text-sm w-2/6 flex justify-end">
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
<span>{{ timeAgo(creation) }}</span>
</Tooltip>
@@ -41,8 +41,8 @@
:key="relatedActivity.creation"
class="flex justify-between text-base"
>
<div class="text-gray-600 w-4/6">
<span class="font-medium text-gray-800">
<div class="text-ink-gray-5 w-4/6">
<span class="font-medium text-ink-gray-8">
{{ relatedActivity.user }}
</span>
<span> {{ relatedActivity.content }}</span>
@@ -50,7 +50,7 @@
<Tooltip
:text="dateFormat(relatedActivity.creation, dateTooltipFormat)"
>
<div class="text-gray-600 text-sm flex justify-end">
<div class="text-ink-gray-5 text-sm flex justify-end">
{{ timeAgo(relatedActivity.creation) }}
</div>
</Tooltip>
@@ -62,7 +62,7 @@
<script setup lang="ts">
import { SelectIcon } from "@/components/icons";
import { dateFormat, dateTooltipFormat, timeAgo } from "@/utils";
import { ref } from "vue";
import { computed, ref } from "vue";
const props = defineProps({
activity: {
type: Object,
@@ -70,7 +70,8 @@ const props = defineProps({
},
});
const { user, content, creation, relatedActivities } = props.activity;
const { user, content, creation } = props.activity;
const relatedActivities = computed(() => props.activity.relatedActivities);
let show_others = ref(false);
</script>
+1
View File
@@ -78,6 +78,7 @@ const chartData = computed(() => {
const _data: AverageResponseData = isDataFetched ? resource.data : props.data;
const dates = _data?.data?.map((item) => item.date) || [];
const seriesData =
_data?.data?.map((item) =>
props.type === "Time" ? item.avg_time : item.count
+84 -18
View File
@@ -35,9 +35,16 @@
</div>
</div>
<!-- Loading State -->
<div
v-if="list.loading && !list.data?.data?.length"
class="flex items-center justify-center h-full w-full absolute top-0 z-100"
>
<LoadingIndicator :scale="8" />
</div>
<!-- List View -->
<ListView
v-if="list.data?.data.length > 0"
v-else-if="list.data?.data.length > 0"
class="flex-1"
:columns="columns"
:rows="rows"
@@ -45,7 +52,7 @@
:options="{
selectable: options.selectable,
showTooltip: false,
resizeColumn: false,
resizeColumn: true,
getRowRoute: (row) => ({
name: options.rowRoute?.name,
params: { [options.rowRoute?.prop]: row.name },
@@ -59,7 +66,7 @@
v-for="column in columns"
:key="column.key"
:item="column"
@columnWidthUpdated="(width) => console.log(width)"
@columnWidthUpdated="handleColumnResize"
/>
</ListHeader>
<ListRows
@@ -106,16 +113,9 @@
"
/>
</div>
<!-- Loading State -->
<div
v-else-if="list.loading"
class="w-full h-full flex items-center justify-center -mt-14"
>
<LoadingIndicator :scale="8" />
</div>
<!-- Empty State -->
<EmptyState
v-else
v-else-if="!list.loading"
:title="emptyState.title"
:icon="emptyState.icon"
:description="emptyState.description"
@@ -146,10 +146,10 @@ import { View, ViewType } from "@/types";
import { getIcon } from "@/utils";
import { useStorage } from "@vueuse/core";
import {
call,
createResource,
Dropdown,
FeatherIcon,
frappeRequest,
ListFooter,
ListHeader,
ListHeaderItem,
@@ -207,7 +207,7 @@ const emit = defineEmits<E>();
const route = useRoute();
const router = useRouter();
const { isManager } = useAuthStore();
const { $dialog } = globalStore();
const { $dialog, $socket } = globalStore();
const { getStatus } = useTicketStatusStore();
const listSelections = ref(new Set());
@@ -258,11 +258,69 @@ const defaultOptions = reactive({
function handleBulkDelete(hide: Function, selections: Set<string>) {
capture("bulk_delete" + props.options.doctype);
call("frappe.desk.reportview.delete_items", {
items: JSON.stringify(Array.from(selections)),
doctype: props.options.doctype,
}).then(() => {
toast.success(__("Item(s) deleted successfully."));
const requested = Array.from(selections);
const requestedCount = requested.length;
const failureMessages: string[] = [];
let successMessage = "";
let failedCount = 0;
const onBulkResult = (data: { message: string; title: string }) => {
const isFailure =
data.title === __("Bulk Operation Failed") ||
data.title === "Bulk Operation Failed";
const isSuccess =
data.title === __("Bulk Operation Successful") ||
data.title === "Bulk Operation Successful";
if (!isFailure && !isSuccess) return;
if (isFailure) {
// Parse how many items failed from the message (Frappe includes the count)
const match = data.message.match(/Failed to delete (\d+) documents?/);
if (match) {
failedCount = parseInt(match[1], 10);
}
failureMessages.push(data.message);
} else {
successMessage = data.message;
}
};
$socket.on("msgprint", onBulkResult);
// Use frappeRequest (not `call`) so per-item delete errors surfaced in
// `_server_messages` get routed through the app's serverMessagesHandler.
// `call` silently drops them on 200 responses.
frappeRequest({
url: "frappe.desk.reportview.delete_items",
params: {
items: JSON.stringify(requested),
doctype: props.options.doctype,
},
}).finally(() => {
$socket.off("msgprint", onBulkResult);
const deletedCount = requestedCount - failedCount;
if (failureMessages.length > 0 && deletedCount > 0) {
// Partial success: some deleted, some failed — show both toasts
toast.success(__("{0} item(s) deleted successfully", [deletedCount]));
for (const msg of failureMessages) {
toast.error(msg);
}
} else if (failureMessages.length > 0) {
// All failed
for (const msg of failureMessages) {
toast.error(msg);
}
} else if (successMessage) {
// All succeeded
toast.success(successMessage);
} else {
// Fallback: no socket messages received (e.g. enqueued for >10 items)
toast.success(__("{0} item(s) queued for deletion", [requestedCount]));
}
hide();
reset();
});
@@ -748,6 +806,14 @@ function handleScrollPosition() {
}, 200);
}
function handleColumnResize() {
isViewUpdated.value = true;
defaultParams.columns = columns.value;
if (!defaultParams.is_default) return;
handleViewUpdate();
isViewUpdated.value = false;
}
onMounted(async () => {
handleScrollPosition();
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="flex flex-wrap gap-2 rounded-lg bg-gray-100 p-2">
<div class="flex flex-wrap gap-2 rounded-lg bg-surface-gray-2 p-2">
<Pill
v-for="item in items"
:key="item.value"
+7 -9
View File
@@ -8,10 +8,6 @@
:label="value"
theme="gray"
variant="subtle"
:class="{
'rounded bg-surface-white hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
variant === 'subtle',
}"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
@@ -28,7 +24,7 @@
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full border-none bg-white hover:bg-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
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"
@@ -44,7 +40,9 @@
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
<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
@@ -58,7 +56,7 @@
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-gray-100': active },
{ 'bg-surface-gray-2': active },
]"
>
<UserAvatar
@@ -66,11 +64,11 @@
:name="getUsernameLabel(option.value)"
size="lg"
/>
<div class="flex flex-col gap-1 p-1 text-gray-800">
<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-gray-600">
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
+1 -1
View File
@@ -21,7 +21,7 @@
:text="avatar.name"
>
<Avatar
class="user-avatar -mr-1.5 ring-2 ring-white transition hover:z-10 hover:scale-110"
class="user-avatar -mr-1.5 ring-2 ring-[var(--surface-white)] transition hover:z-10 hover:scale-110"
shape="circle"
:image="avatar.image"
:label="avatar.label"
+1 -1
View File
@@ -2,7 +2,7 @@
<header class="border-b px-5 py-2.5">
<div class="flex items-center justify-between">
<slot name="title">
<div v-if="title" class="text-lg font-medium text-gray-900">
<div v-if="title" class="text-lg font-medium text-ink-gray-9">
{{ title }}
</div>
</slot>
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="relative" :style="{ width: `${sidebarWidth}px` }">
<slot v-bind="{ sidebarResizing, sidebarWidth }" />
<div
class="absolute z-1 h-full w-1 cursor-col-resize bg-surface-gray-4 opacity-0 transition-opacity hover:opacity-100"
class="absolute z-10 h-full w-1 cursor-col-resize bg-surface-gray-4 opacity-0 transition-opacity hover:opacity-100"
:class="{
'opacity-100': sidebarResizing,
'left-0': side == 'right',
@@ -5,6 +5,7 @@
size: '4xl',
}"
@vue:unmounted="resetFilter"
@after-leave="onAfterLeave"
>
<template #body>
<div class="max-h-[575px]" :style="{ height: 'calc(100vh - 8rem)' }">
@@ -26,7 +27,7 @@
@input="search = $event"
:placeholder="__('Search')"
type="text"
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
class="focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
@@ -70,7 +71,7 @@
<div
v-for="template in savedReplyListResource?.data"
:key="template.name"
class="flex h-56 cursor-pointer flex-col gap-2 rounded-lg border p-3 hover:bg-gray-100 relative"
class="flex h-56 cursor-pointer flex-col gap-2 rounded-lg border p-3 hover:bg-surface-gray-2 relative"
@click="onTemplateSelect(template)"
>
<div class="text-base font-semibold truncate border-b pb-2">
@@ -80,7 +81,7 @@
v-if="template.message"
:content="template.message"
:editable="false"
editor-class="!prose-sm max-w-none !text-sm text-gray-600 focus:outline-none"
editor-class="!prose-sm max-w-none !text-sm text-ink-gray-5 focus:outline-none"
class="flex-1 overflow-hidden pointer-events-none"
/>
<div
@@ -88,7 +89,7 @@
selectedTemplate.name === template.name &&
selectedTemplate.isLoading
"
class="flex items-center justify-center absolute top-0 left-0 w-full h-full bg-black/20 rounded-lg"
class="flex items-center justify-center absolute top-0 left-0 w-full h-full bg-surface-gray-7/20 rounded-lg"
>
<LoadingIndicator class="size-4" />
</div>
@@ -102,7 +103,7 @@
class="mt-2"
>
<div class="flex h-56 flex-col items-center justify-center">
<div class="text-p-sm text-gray-500">
<div class="text-p-sm text-ink-gray-4">
{{ __("No saved replies found") }}
</div>
</div>
@@ -130,8 +131,7 @@ import {
TextEditor,
} from "frappe-ui";
import { storeToRefs } from "pinia";
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { showEmailBox } from "../pages/ticket/modalStates";
import { computed, nextTick, ref, watch } from "vue";
import {
setActiveSettingsTab,
showSettingsModal,
@@ -199,6 +199,14 @@ const selectedTemplate = ref({
name: "",
isLoading: false,
});
const pendingTemplate = ref<string | null>(null);
function onAfterLeave() {
if (pendingTemplate.value !== null) {
emit("apply", pendingTemplate.value);
pendingTemplate.value = null;
}
}
const scope = computed(() => {
return filters.value.find((f) => f.value === activeFilter.value)?.value;
@@ -217,10 +225,6 @@ const savedReplyListResource = createListResource({
pageLength: 999,
});
onUnmounted(() => {
showEmailBox.value = true;
});
const onTemplateSelect = (template: SavedReply) => {
if (selectedTemplate.value.isLoading) return;
selectedTemplate.value = {
@@ -238,7 +242,10 @@ const onTemplateSelect = (template: SavedReply) => {
name: "",
isLoading: false,
};
emit("apply", data);
// If user cancelled (Escape/outside click) while API was in flight, discard
if (!show.value) return;
pendingTemplate.value = data;
show.value = false;
capture("saved_reply_applied");
},
});
+5 -5
View File
@@ -6,7 +6,7 @@
<div class="mb-2 font-medium pl-2" v-if="!hideViewAll">
These articles may already cover what you are looking for
<RouterLink
class="group cursor-pointer space-x-1 hover:text-gray-900"
class="group cursor-pointer space-x-1 hover:text-ink-gray-9"
:to="{
name: 'CustomerKnowledgeBase',
}"
@@ -25,7 +25,7 @@
class="rounded-md border-2 p-2 border-hidden hover:bg-surface-gray-2"
>
<RouterLink
class="group cursor-pointer hover:text-gray-900 flex flex-col gap-1"
class="group cursor-pointer hover:text-ink-gray-9 flex flex-col gap-1"
:to="{
name: 'ArticlePublic',
params: {
@@ -39,7 +39,7 @@
<dt class="font-base">{{ a.subject }} - {{ a.headings }}</dt>
<!-- eslint-disable-next-line vue/no-v-html -->
<dd
class="font-base text-p-sm text-gray-600 line-clamp-1"
class="font-base text-p-sm text-ink-gray-5 line-clamp-1"
v-html="a.description"
></dd>
</RouterLink>
@@ -55,7 +55,7 @@
<LucideSearch class="size-8 text-ink-gray-3" />
<div class="flex items-center flex-col justify-center">
<p class="font-base">No answers found</p>
<span class="font-base text-p-sm text-gray-600 text-center"
<span class="font-base text-p-sm text-ink-gray-5 text-center"
>Rephrase the question and try again with some keywords</span
>
</div>
@@ -67,7 +67,7 @@
<LucideSearch class="size-8 text-ink-gray-3" />
<div class="flex items-center flex-col justify-center">
<p class="font-base">Searching...</p>
<span class="font-base text-p-sm text-gray-600 text-center"
<span class="font-base text-p-sm text-ink-gray-5 text-center"
>Please wait while we search for the answers</span
>
</div>
+1 -1
View File
@@ -23,7 +23,7 @@
v-if="option.image"
:image="option.image"
:label="option.label"
class="border-2 border-white flex-shrink-0"
class="border-2 border-[var(--surface-white)] flex-shrink-0"
size="sm"
/>
</div>
+2 -2
View File
@@ -33,8 +33,8 @@
<transition
enter-active-class="duration-300 ease-in"
leave-active-class="duration-300 ease-[cubic-bezier(0, 1, 0.5, 1)]"
enter-to-class="max-h-[200px] overflow-hidden"
leave-from-class="max-h-[200px] overflow-hidden"
enter-to-class="max-h-[2000px] overflow-hidden"
leave-from-class="max-h-[2000px] overflow-hidden"
enter-from-class="max-h-0 opacity-0"
leave-to-class="max-h-0 opacity-0"
>
+1 -1
View File
@@ -16,7 +16,7 @@
</template>
<template #body="{ togglePopover }">
<div
class="p-1 text-ink-gray-6 top-1 absolute w-[--reka-popper-anchor-width] bg-white shadow-2xl rounded"
class="p-1 text-ink-gray-6 top-1 absolute w-[--reka-popper-anchor-width] bg-surface-white shadow-2xl rounded"
:class="bodyClass"
>
<div class="max-h-52 overflow-y-auto">
+14 -25
View File
@@ -22,7 +22,7 @@
@input="search = $event"
:placeholder="__('Search')"
type="text"
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
class="focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
@@ -49,9 +49,9 @@
</template>
</Button>
</template>
<template #item="{ item }">
<template #item-label="{ item }">
<button
class="group flex text-ink-gray-6 gap-4 h-7 w-full justify-between items-center rounded p-2 text-base hover:bg-surface-gray-3"
class="group flex text-ink-gray-6 gap-4 w-full justify-between items-center rounded text-base"
@click="item.onClick"
>
<div class="flex items-center justify-between flex-1">
@@ -84,35 +84,24 @@
/>
</div>
<!-- Empty State -->
<div
<EmptyState
v-if="!agents.loading && !agents.data?.length"
class="flex flex-col items-center justify-center gap-4 h-full"
>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex items-center justify-center"
>
<AgentIcon class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No agent found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{
activeFilter.length
? __("Change your search terms or filters")
: __("Add one to get started.")
}}
</div>
</div>
</div>
variant="badge"
:icon="AgentIcon"
title="No agent found"
:description="
activeFilter.length
? 'Change your search terms or filters'
: 'Add one to get started.'
"
/>
<!-- Agent List -->
<div
class="w-full"
v-if="!agents.loading && Boolean(agents.data?.length)"
>
<div
class="grid grid-cols-8 items-center gap-3 text-sm text-gray-600"
class="grid grid-cols-8 items-center gap-3 text-sm text-ink-gray-5"
>
<div class="col-span-6 text-p-sm">{{ __("Agent name") }}</div>
</div>
@@ -42,7 +42,7 @@
</template>
<template #body="{ togglePopover }">
<div
class="p-1 text-ink-gray-7 mt-1 bg-white shadow-xl rounded w-[--reka-popper-anchor-width]"
class="p-1 text-ink-gray-7 mt-1 bg-surface-white shadow-xl rounded w-[--reka-popper-anchor-width]"
>
<div
v-for="option in ticketRoutingOptions"
@@ -97,7 +97,7 @@
:placement="'top'"
>
<div
class="text-xs rounded-full select-none bg-blue-600 text-white p-0.5 px-2"
class="text-xs rounded-full select-none bg-surface-blue-3 text-ink-white p-0.5 px-2"
>
{{ __("Last") }}
</div>
@@ -10,11 +10,13 @@
/>
</template>
<template #body="{ togglePopover }">
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl w-60">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base shadow-2xl w-60"
>
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
class="form-input w-full bg-transparent"
type="text"
@change="
(e) => {
@@ -49,7 +51,7 @@
>
<li
class="flex items-center rounded p-1.5 w-full text-base"
:class="{ 'bg-gray-100': active }"
:class="{ 'bg-surface-gray-2': active }"
>
<div class="flex gap-2 items-center w-full select-none">
<Avatar
@@ -71,7 +73,7 @@
</ComboboxOption>
<li
v-if="users.length == 0"
class="mt-1.5 rounded-md p-1.5 text-base text-gray-600"
class="mt-1.5 rounded-md p-1.5 text-base text-ink-gray-5"
>
{{ __("No results found") }}
</li>
@@ -1,6 +1,6 @@
<template>
<div
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded"
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-surface-menu-bar rounded"
>
<div
@click="assignmentRulesActiveScreen = { screen: 'view', data: data }"
@@ -12,13 +12,7 @@
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-lg hover:opacity-70 !pr-0 !max-w-96 !justify-start"
/>
<Badge
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
<UnsavedBadge :show="isDirty" />
</div>
</template>
<template #header-actions>
@@ -47,7 +41,7 @@
<template #content>
<div
v-if="getAssignmentRuleData.loading"
class="flex items-center h-full justify-center"
class="flex items-center justify-center h-[stretch] absolute w-[stretch] left-0 top-5.5"
>
<LoadingIndicator class="w-4" />
</div>
@@ -90,12 +84,12 @@
</template>
<template #body="{ togglePopover }">
<div
class="p-1 text-ink-gray-6 top-1 absolute bg-white shadow-2xl rounded w-[--reka-popper-anchor-width]"
class="p-1 text-ink-gray-6 top-1 absolute bg-surface-white shadow-2xl rounded w-[--reka-popper-anchor-width]"
>
<div
v-for="option in priorityOptions"
:key="option.value"
class="p-2 cursor-pointer hover:bg-gray-50 text-base flex items-center justify-between rounded"
class="p-2 cursor-pointer hover:bg-surface-menu-bar text-base flex items-center justify-between rounded"
@click="
assignmentRuleData.priority = option.value;
togglePopover();
@@ -164,7 +158,7 @@
</template>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
class="text-sm text-ink-gray-6 p-2 bg-surface-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.assignCondition }}</code>
</div>
@@ -175,7 +169,7 @@
</div>
<div class="mt-5">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-md p-3 py-4"
v-if="
!useNewUIForAssignCondition &&
assignmentRuleData.assignCondition
@@ -245,7 +239,7 @@
</template>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
class="text-sm text-ink-gray-6 p-2 bg-surface-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.unassignCondition }}</code>
</div>
@@ -256,7 +250,7 @@
</div>
<div class="mt-5">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-md p-3 py-4"
v-if="
!useNewUIForUnassignCondition &&
assignmentRuleData.unassignCondition
@@ -356,6 +350,7 @@ import { convertToConditions } from "@/utils";
import { disableSettingModalOutsideClick } from "../settingsModal";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
const isDirty = ref(false);
const initialData = ref(null);
@@ -36,7 +36,7 @@
@input="assignmentRuleSearchQuery = $event"
:placeholder="__('Search')"
type="text"
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
class="focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
@@ -25,7 +25,7 @@
</div>
</div>
<div v-else>
<div class="grid grid-cols-12 items-center gap-4 text-sm text-gray-600">
<div class="grid grid-cols-12 items-center gap-4 text-sm text-ink-gray-5">
<div class="col-span-7 ml-2">{{ __("Assignment rule") }}</div>
<div class="col-span-3">{{ __("Priority") }}</div>
<div class="col-span-2">{{ __("Enabled") }}</div>
@@ -7,7 +7,7 @@
/>
<div
v-if="props.conditions.length == 0"
class="flex p-4 items-center cursor-pointer justify-center gap-2 text-sm border border-gray-300 text-gray-600 rounded-md"
class="flex p-4 items-center cursor-pointer justify-center gap-2 text-sm border border-outline-gray-2 text-ink-gray-5 rounded-md"
@click="
props.conditions.push(['', '', '']);
validateAssignmentRule(props.name);
@@ -1,5 +1,5 @@
<template>
<div class="rounded-md border px-2 border-gray-300 text-sm">
<div class="rounded-md border px-2 border-outline-gray-2 text-sm">
<div
class="grid p-2 px-4 items-center"
style="grid-template-columns: 3fr 1fr"
@@ -7,7 +7,7 @@
<div
v-for="column in columns"
:key="column.key"
class="text-gray-600 overflow-hidden whitespace-nowrap text-ellipsis"
class="text-ink-gray-5 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ column.label }}
</div>
@@ -1,6 +1,6 @@
<template>
<div
class="flex justify-between items-center border-gray-200 p-2 cursor-pointer hover:bg-gray-50 rounded h-14"
class="flex justify-between items-center border-outline-gray-modals p-2 cursor-pointer hover:bg-surface-menu-bar rounded h-14"
>
<!-- avatar and name -->
<div class="flex justify-between items-center gap-2">
@@ -22,7 +22,7 @@
class="-ml-2 grow"
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
>
<div class="flex text-sm text-gray-600">
<div class="flex text-sm text-ink-gray-5">
<div class="ml-2">{{ __("Email account name") }}</div>
</div>
<hr class="mx-2 mt-2" />
@@ -38,24 +38,13 @@
</div>
</div>
<!-- fallback if no email accounts -->
<div
<EmptyState
v-else
class="flex flex-col items-center justify-center gap-4 h-full"
>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<EmailIcon class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No email account found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{ __("Add one to get started.") }}
</div>
</div>
</div>
variant="badge"
:icon="EmailIcon"
title="No email account found"
description="Add one to get started."
/>
</template>
</SettingsLayoutBase>
</template>
@@ -65,6 +54,7 @@ import { EmailAccount } from "@/types";
import { createListResource } from "frappe-ui";
import EmailAccountCard from "./EmailAccountCard.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { EmailIcon } from "../icons";
const emit = defineEmits(["update:step"]);
+7 -5
View File
@@ -27,17 +27,17 @@
<!-- email service provider info -->
<div>
<div
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-gray-200"
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-outline-gray-modals"
>
<CircleAlert
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-blue-500"
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-2"
/>
<div class="text-wrap text-xs text-gray-700">
<div class="text-wrap text-xs text-ink-gray-7">
{{ selectedService.info }}
<a
:href="selectedService.link"
target="_blank"
class="text-blue-500 underline"
class="text-ink-blue-2 underline"
>here</a
>.
</div>
@@ -129,7 +129,9 @@
:name="field.name"
:type="field.type"
/>
<p class="text-gray-500 text-p-sm">{{ field.description }}</p>
<p class="text-ink-gray-4 text-p-sm">
{{ field.description }}
</p>
</div>
</div>
<ErrorMessage v-if="error" class="ml-1" :message="error" />
+4 -4
View File
@@ -13,12 +13,12 @@
/>
</div>
<div
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-gray-200"
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-outline-gray-modals"
>
<CircleAlert
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-2"
/>
<div class="text-wrap text-xs text-gray-700 flex flex-col gap-1">
<div class="text-wrap text-xs text-ink-gray-7 flex flex-col gap-1">
<span>
{{ info.description }}
<a
@@ -122,7 +122,7 @@
:name="field.name"
:type="field.type"
/>
<p class="text-p-sm text-gray-500">{{ field.description }}</p>
<p class="text-p-sm text-ink-gray-4">{{ field.description }}</p>
</div>
</div>
<ErrorMessage v-if="error" class="ml-1" :message="error" />
@@ -405,7 +405,7 @@ function pullEmails() {
toast.create({
message: __("Pulling emails, this may take a few minutes."),
icon: h(CircleAlert, { class: "text-blue-500" }),
icon: h(CircleAlert, { class: "text-ink-blue-2" }),
});
call("frappe.email.doctype.email_account.email_account.pull_emails", {
@@ -17,13 +17,7 @@
{{ props.title }}
</h1>
</div>
<Badge
v-if="unsavedChanges"
:variant="'subtle'"
:theme="'orange'"
size="sm"
:label="__('Unsaved')"
/>
<UnsavedBadge :show="unsavedChanges" />
</div>
</template>
<template #header-actions>
@@ -71,7 +65,7 @@
:oninput="() => setUnsavedChanges()"
/>
<div class="flex gap-x-1 items-start justify-between">
<p class="text-sm text-gray-700 leading-5">
<p class="text-sm text-ink-gray-7 leading-5">
{{
__(
"Find out all of the variables that can be used in the content"
@@ -139,6 +133,7 @@ import { createResource, Switch, LoadingIndicator, Button } from "frappe-ui";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { disableSettingModalOutsideClick } from "../settingsModal";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
const props = defineProps<{
title: string;
@@ -9,41 +9,42 @@
>
<template #content>
<ul class="isolate -ml-3">
<li
v-for="notification in notifications"
<div
v-for="(notification, index) in notifications"
:key="notification.name"
class="flex items-center justify-between p-3 rounded relative"
>
<div class="flex flex-col gap-1">
<h2
class="text-base font-medium text-ink-gray-7 relative z-10 pointer-events-none"
<li class="flex items-center justify-between p-3 rounded relative">
<div class="flex flex-col gap-1">
<h2
class="text-base font-medium text-ink-gray-7 relative z-10 pointer-events-none"
>
{{ __(notification.label) }}
</h2>
<p
class="text-sm text-ink-gray-5 truncate relative z-10 pointer-events-none"
>
{{ __(notification.description) }}
</p>
</div>
<FeatherIcon
name="chevron-right"
class="text-ink-gray-7 size-4 relative z-10 pointer-events-none"
/>
<div
class="w-full h-full absolute top-0 left-0 hover:bg-surface-menu-bar rounded-[inherit]"
@click="
() => {
props.onSelect(notification);
}
"
>
{{ notification.label }}
</h2>
<p
class="text-sm text-ink-gray-5 truncate relative z-10 pointer-events-none"
>
{{ notification.description }}
</p>
</div>
<FeatherIcon
name="chevron-right"
class="text-ink-gray-7 size-4 relative z-10 pointer-events-none"
/>
<button
type="button"
class="w-full h-full absolute top-0 left-0 hover:bg-gray-50 rounded-[inherit]"
@click="
() => {
props.onSelect(notification);
}
"
>
<span class="sr-only">{{
__("customize {0}", notification.name)
}}</span>
</button>
</li>
<span class="sr-only">{{
__("customize {0}", notification.name)
}}</span>
</div>
</li>
<hr v-if="index < notifications.length - 1" class="mx-2" />
</div>
</ul>
</template>
</SettingsLayoutBase>
@@ -1,12 +1,12 @@
<template>
<div
class="flex size-8 cursor-pointer items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200"
:class="{ 'ring-2 ring-blue-500': selected }"
class="flex size-8 cursor-pointer items-center justify-center rounded-full bg-surface-gray-2 hover:bg-surface-gray-3"
:class="{ 'ring-2 ring-outline-blue-3': selected }"
>
<img v-if="logoValue" :src="logoValue" class="size-4.5" />
<LucideMail v-else class="size-4.5 text-gray-700" />
<LucideMail v-else class="size-4.5 text-ink-gray-7" />
</div>
<p v-if="serviceName" class="text-center text-p-xs text-gray-700">
<p v-if="serviceName" class="text-center text-p-xs text-ink-gray-7">
{{ serviceName }}
</p>
</template>
@@ -10,7 +10,7 @@
@click="handleBackNavigation"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
/>
<Badge v-if="isDirty" theme="orange"> {{ __("Unsaved") }} </Badge>
<UnsavedBadge :show="isDirty" />
</div>
</template>
<template #header-actions>
@@ -94,6 +94,7 @@ import FieldDependencyFieldsSelection from "./FieldDependencyFieldsSelection.vue
import FieldDependencyValueSelection from "./FieldDependencyValueSelection.vue";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
const props = defineProps({
fieldDependencyName: {
@@ -10,6 +10,7 @@
:options="parentFields"
:disabled="!isNew"
:open-on-focus="true"
class="w-full"
/>
</div>
<div class="flex-1 flex flex-col gap-1.5">
@@ -22,6 +23,7 @@
:options="state.childFields"
:disabled="!state.selectedParentField || !isNew"
:open-on-focus="true"
class="w-full"
/>
</div>
</div>
@@ -42,27 +42,16 @@
</div>
<!-- Empty State -->
<div
<EmptyState
v-if="
!fieldDependenciesList.loading &&
!fieldDependenciesList.data?.length
"
class="flex flex-col items-center justify-center gap-4 p-4 h-full"
>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<FieldDependencyIcon class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No field dependency found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{ __("Add one to get started.") }}
</div>
</div>
</div>
variant="badge"
:icon="FieldDependencyIcon"
title="No field dependency found"
description="Add one to get started."
/>
<div
class="w-full -ml-2"
@@ -73,7 +62,7 @@
>
<div>
<div
class="grid grid-cols-11 items-center gap-4 text-sm text-gray-600"
class="grid grid-cols-11 items-center gap-4 text-sm text-ink-gray-5"
>
<div class="col-span-7 ml-2">{{ __("Name") }}</div>
<div class="col-span-2">{{ __("Created by") }}</div>
@@ -85,7 +74,7 @@
:key="row.name"
>
<div
class="grid grid-cols-11 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded h-12.5"
class="grid grid-cols-11 items-center gap-4 cursor-pointer hover:bg-surface-menu-bar rounded h-12.5"
>
<div
@click.stop="$emit('update:step', 'fd', row.name)"
@@ -16,7 +16,7 @@
class="w-full"
>
<template #prefix>
<LucideSearch class="h-4 w-4 text-gray-500" />
<LucideSearch class="h-4 w-4 text-ink-gray-4" />
</template>
</FormControl>
<div class="flex-1 overflow-y-auto hide-scrollbar basis-0">
@@ -77,7 +77,7 @@
class="w-full"
>
<template #prefix>
<LucideSearch class="h-4 w-4 text-gray-500" />
<LucideSearch class="h-4 w-4 text-ink-gray-4" />
</template>
</FormControl>
<div class="flex-1 overflow-y-auto hide-scrollbar basis-0">
@@ -5,30 +5,28 @@
<h1 class="text-lg font-semibold text-ink-gray-8">
{{ __("General") }}
</h1>
<Badge
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved')"
v-if="isDirty || isWebsiteSettingsChanged"
/>
<UnsavedBadge :show="isDirty" />
</div>
</template>
<template #header-actions>
<Button
:label="__('Save')"
variant="solid"
@click="saveSettings"
:loading="
saveSettingsResource.loading || saveWebsiteSettingsResource.loading
"
:disabled="!isDirty && !isWebsiteSettingsChanged"
/>
<Transition name="fade">
<div v-if="isDirty">
<Button
:label="__('Save')"
variant="solid"
@click="saveSettings"
:loading="
saveSettingsResource.loading ||
saveWebsiteSettingsResource.loading
"
/>
</div>
</Transition>
</template>
<template #content>
<div
v-if="settingsDataResource.loading && !settingsDataResource.data"
class="flex items-center justify-center mt-12"
class="flex items-center justify-center h-[stretch] absolute w-[stretch] left-0 top-5.5"
>
<LoadingIndicator class="w-4" />
</div>
@@ -40,7 +38,7 @@
<WorkflowKnowledgebaseSettings />
<hr class="my-8" />
<div>
<div class="text-base font-semibold text-gray-900">
<div class="text-base font-semibold text-ink-gray-9">
{{ __("User Signup") }}
</div>
<div class="flex items-center justify-between mt-6">
@@ -57,12 +55,18 @@
<Switch v-model="disableSignup" />
</div>
</div>
<ERPNextIntegrationSettings />
</div>
</template>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
import ERPNextIntegrationSettings from "@/components/erpnext-integration/ERPNextIntegrationSettings.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { useConfigStore } from "@/stores/config";
import { __ } from "@/translation";
import { HDSettings, HDSettingsSymbol } from "@/types";
import {
Badge,
Button,
@@ -71,14 +75,14 @@ import {
Switch,
toast,
} from "frappe-ui";
import { computed, provide, ref, watch } from "vue";
import { disableSettingModalOutsideClick } from "../settingsModal";
import Branding from "./components/Branding.vue";
import TicketSettings from "./components/TicketSettings.vue";
import WorkflowKnowledgebaseSettings from "./components/WorkflowKnowledgebaseSettings.vue";
import { computed, provide, ref, watch } from "vue";
import { __ } from "@/translation";
import { disableSettingModalOutsideClick } from "../settingsModal";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { HDSettings, HDSettingsSymbol } from "@/types";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
const configStore = useConfigStore();
const isDirty = ref(false);
const initialData = ref<null | string>(null);
@@ -166,6 +170,7 @@ const saveSettingsResource = createResource({
onSuccess(data: HDSettings) {
settingsData.value = transformData(data);
initialData.value = JSON.stringify(settingsData.value);
configStore.configResource.reload();
},
});
@@ -228,6 +233,18 @@ const saveWebsiteSettingsResource = createResource({
});
const saveSettings = async () => {
if (
settingsData.value.restrictTicketsByAgentGroup &&
!settingsData.value.doNotRestrictTicketsWithoutAnAgentGroup &&
!settingsData.value.assignWithinTeam
) {
toast.error(
__(
"Please select at least one restriction option for teams in the settings."
)
);
return;
}
const promises = [];
if (isDirty.value) {
promises.push(saveSettingsResource.submit());
@@ -240,17 +257,50 @@ const saveSettings = async () => {
});
};
const toggleFields = [
"isFeedbackMandatory",
"enableCommentReactions",
"disableSavedRepliesGlobalScope",
"allowAnyoneToCreateTickets",
"preferKnowledgeBase",
"skipEmailWorkflow",
] as const;
// Track dirty state for non-toggle fields only
watch(
settingsData,
(data) => {
if (!initialData.value) return;
isDirty.value = JSON.stringify(data) !== initialData.value;
if (isDirty.value) {
disableSettingModalOutsideClick.value = true;
} else {
disableSettingModalOutsideClick.value = false;
}
const initial = JSON.parse(initialData.value);
isDirty.value = Object.keys(data).some(
(key) =>
!(toggleFields as readonly string[]).includes(key) &&
JSON.stringify(data[key as keyof typeof data]) !==
JSON.stringify(initial[key])
);
disableSettingModalOutsideClick.value = isDirty.value;
},
{ deep: true }
);
// auto save when any toggle field changes
watch(
() => toggleFields.map((f) => settingsData.value[f]),
async (newVals) => {
if (!initialData.value) return;
const initial = JSON.parse(initialData.value);
if (newVals.some((v, i) => v !== initial[toggleFields[i]])) {
await saveSettingsResource.submit();
toast.success(__("Settings updated"));
}
}
);
watch(disableSignup, async (newVal) => {
if (!websiteSettingsResource.data) return;
if (newVal !== Boolean(websiteSettingsResource.data.disable_signup)) {
await saveWebsiteSettingsResource.submit();
toast.success(__("Settings updated"));
}
});
</script>
@@ -1,6 +1,6 @@
<template>
<div v-if="settingsData">
<div class="text-base font-semibold text-gray-900">
<div class="text-base font-semibold text-ink-gray-9">
{{ __("Branding") }}
</div>
<FormControl
@@ -4,7 +4,7 @@
>
<div class="flex flex-col sm:flex-row items-center gap-3.5">
<div
class="flex items-center justify-center min-w-16 min-h-16 rounded-lg overflow-hidden border border-gray-100"
class="flex items-center justify-center min-w-16 min-h-16 rounded-lg overflow-hidden border border-outline-gray-1"
>
<Avatar
v-if="props.image"
@@ -1,6 +1,6 @@
<template>
<div>
<div class="text-base font-semibold text-gray-900">
<div class="text-base font-semibold text-ink-gray-9">
{{ __("Ticket Settings") }}
</div>
<div class="mt-6 flex flex-col gap-6">
@@ -224,7 +224,7 @@
v-if="settingsData.enableOutsideHoursBanner"
class="flex gap-x-1 items-start justify-between"
>
<p class="text-sm text-gray-700 leading-5">
<p class="text-sm text-ink-gray-7 leading-5">
{{
__(
"Find out all of the variables that can be used in the content"
@@ -1,6 +1,6 @@
<template>
<div>
<div class="text-base font-semibold text-gray-900">
<div class="text-base font-semibold text-ink-gray-9">
{{ __("Workflow & Knowledge Base Settings") }}
</div>
<div class="mt-6 flex flex-col gap-6">
@@ -1,12 +1,12 @@
<template>
<div class="w-max mx-auto">
<div class="text-base font-medium mb-2 text-gray-800 ml-2.5">
<div class="text-base font-medium mb-2 text-ink-gray-8 ml-2.5">
{{ formattedMonth }}
</div>
<div class="rounded-md text-sm">
<div class="flex items-center text-xs uppercase">
<div
class="flex size-7.5 items-center justify-center text-center text-gray-600"
class="flex size-7.5 items-center justify-center text-center text-ink-gray-5"
v-for="(d, i) in ['s', 'm', 't', 'w', 't', 'f', 's']"
:key="i"
>
@@ -20,7 +20,7 @@
<div
class="flex size-7 cursor-pointer text-orange-700 bg-yellow-100 items-center justify-center rounded hover:bg-yellow-100 select-none m-[1px]"
:class="{
'!text-ink-gray-4 !bg-gray-100': isWeekOff(date),
'!text-ink-gray-4 !bg-surface-gray-2': isWeekOff(date),
}"
@mouseover="handleMouseEnter(getFormattedDate(date), open)"
@mouseleave="handleMouseLeave(getFormattedDate(date), close)"
@@ -37,7 +37,7 @@
</template>
<template #body-main="{ close: closePopover, open: openPopover }">
<div
class="p-3 flex gap-2.5 text-ink-gray-9 w-80 border border-gray-100 rounded-md"
class="p-3 flex gap-2.5 text-ink-gray-9 w-80 border border-outline-gray-1 rounded-md"
@mouseover="
handleMouseEnter(getFormattedDate(date), openPopover)
"
@@ -77,7 +77,7 @@
#body-main="{ close: closeDropdown, open: openDropdown }"
>
<div
class="p-2 flex flex-col gap-1 w-40 text-ink-gray-9 border border-gray-100 rounded-md"
class="p-2 flex flex-col gap-1 w-40 text-ink-gray-9 border border-outline-gray-1 rounded-md"
@mouseover="
handleMouseEnter(getFormattedDate(date), openPopover);
handleMouseEnter(
@@ -137,7 +137,7 @@
'text-ink-gray-3':
// @ts-ignore
date.getMonth() !== currentMonth - 1 || !isDateInRange(date),
'bg-black text-ink-white hover:!bg-black/80 hover:text-ink-white':
'bg-surface-gray-7 text-ink-white hover:!bg-surface-gray-7/80 hover:text-ink-white':
getFormattedDate(date) === dateValue && isDateInRange(date),
'opacity-50 cursor-not-allowed': !isDateInRange(date),
}"
@@ -25,7 +25,7 @@
</div>
</div>
<div v-else>
<div class="flex text-sm text-gray-600">
<div class="flex text-sm text-ink-gray-5">
<div class="ml-2">{{ __("Schedule name") }}</div>
</div>
<hr class="mx-2 mt-2" />
@@ -1,5 +1,7 @@
<template>
<div class="flex items-center cursor-pointer hover:bg-gray-50 rounded">
<div
class="flex items-center cursor-pointer hover:bg-surface-menu-bar rounded"
>
<div
class="w-full pl-2 flex flex-col justify-center h-14"
@click="holidayListActiveScreen = { screen: 'view', data: data }"
@@ -10,13 +10,7 @@
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
/>
<Badge
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
<UnsavedBadge :show="isDirty" />
</div>
</template>
<template #header-actions>
@@ -190,7 +184,7 @@
}}</span>
</div>
<div class="gap-1 flex items-center">
<span class="bg-gray-100 size-4 rounded-sm" />
<span class="bg-surface-gray-2 size-4 rounded-sm" />
<span class="text-sm text-ink-gray-6">{{
__("Recurring holidays")
}}</span>
@@ -247,6 +241,7 @@ import HolidaysCalendarView from "./HolidaysCalendarView.vue";
import AddHolidayModal from "./Modals/AddHolidayModal.vue";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
import { HolidayListResourceSymbol } from "@/types";
import { HDServiceHolidayList } from "@/types/doctypes";
@@ -26,7 +26,7 @@
@input="holidaySearchRef = $event"
:placeholder="__('Search')"
type="text"
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
class="focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
@@ -1,5 +1,5 @@
<template>
<div class="p-6.5 px-5 rounded-xl border border-gray-300">
<div class="p-6.5 px-5 rounded-xl border border-outline-gray-2">
<div class="mb-6.5 flex justify-between items-center">
<div class="ml-1">
<Popover v-if="startYear !== endYear">
@@ -19,7 +19,7 @@
v-for="year in yearsList"
:key="year"
ref="yearItems"
class="cursor-pointer px-3 py-1.5 text-sm hover:bg-gray-100 flex items-center justify-between"
class="cursor-pointer px-3 py-1.5 text-sm hover:bg-surface-gray-2 flex items-center justify-between"
@click="onYearChange(togglePopover, year)"
>
{{ year }}
@@ -85,8 +85,8 @@
:class="[
'size-1.5 rounded-full cursor-pointer',
{
'bg-black': visibleMonths === 'first-half',
'bg-gray-400': visibleMonths === 'second-half',
'bg-surface-gray-7': visibleMonths === 'first-half',
'bg-surface-gray-4': visibleMonths === 'second-half',
},
]"
@click="visibleMonths = 'first-half'"
@@ -95,8 +95,8 @@
:class="[
'size-1.5 rounded-full cursor-pointer',
{
'bg-black': visibleMonths === 'second-half',
'bg-gray-400': visibleMonths === 'first-half',
'bg-surface-gray-7': visibleMonths === 'second-half',
'bg-surface-gray-4': visibleMonths === 'first-half',
},
]"
@click="visibleMonths = 'second-half'"
@@ -1,5 +1,5 @@
<template>
<div class="rounded-md border px-2 border-gray-300 text-sm">
<div class="rounded-md border px-2 border-outline-gray-2 text-sm">
<div
class="grid p-2 px-4 items-center gap-2"
:style="{
@@ -9,7 +9,7 @@
<div
v-for="column in columns"
:key="column.key"
class="text-gray-600 overflow-hidden whitespace-nowrap text-ellipsis"
class="text-ink-gray-5 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ column.label }}
</div>
@@ -30,7 +30,7 @@
:type="'text'"
placeholder="Description"
v-model="holiday[column.key]"
class="!bg-white w-full text-base px-0 focus:!ring-0 border-none hover:bg-white outline-none no-underline focus:!outline-none"
class="!bg-surface-white w-full text-base px-0 focus:!ring-0 border-none hover:bg-surface-white outline-none no-underline focus:!outline-none"
/>
<div v-else>
{{ dayjs(holiday[column.key]).format("DD MMM YYYY") }}
@@ -48,7 +48,7 @@
</div>
<hr v-if="index !== holidays.length - 1" />
</div>
<div v-if="holidays?.length === 0" class="text-center p-4 text-gray-600">
<div v-if="holidays?.length === 0" class="text-center p-4 text-ink-gray-5">
No items in the list
</div>
</div>
@@ -1,5 +1,5 @@
<template>
<div class="rounded-md border p-1 border-gray-300 text-sm">
<div class="rounded-md border p-1 border-outline-gray-2 text-sm">
<div
class="grid p-2 items-center"
:style="{
@@ -10,7 +10,7 @@
<div
v-for="column in columns"
:key="column.key"
class="text-gray-600 overflow-hidden whitespace-nowrap text-ellipsis"
class="text-ink-gray-5 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ column.label }}
</div>
@@ -43,7 +43,7 @@
</div>
<hr class="my-0.5" v-if="index !== holidays.length - 1" />
</div>
<div v-if="holidays?.length === 0" class="text-center p-4 text-gray-600">
<div v-if="holidays?.length === 0" class="text-center p-4 text-ink-gray-5">
No items in the list
</div>
</div>
@@ -65,7 +65,7 @@
>
<template #body-content>
<div v-if="!props.holidayData.from_date || !props.holidayData.to_date">
<div class="text-center p-4 text-gray-600">
<div class="text-center p-4 text-ink-gray-5">
Please select start and end date first
</div>
</div>
+26 -10
View File
@@ -14,14 +14,20 @@
:debounce="100"
:description="__('Comma separated emails to invite.')"
/>
<FormControl
:label="__('Role')"
type="select"
:required="true"
:options="roleOptions"
v-model="role"
:description="roleDescription"
/>
<div class="space-y-1.5">
<label class="block text-xs text-ink-gray-5">
{{ __("Role") }}
<span class="text-ink-red-3 select-none" aria-hidden="true">*</span>
</label>
<Select :options="roleOptions" v-model="role" required class="w-full">
<template #suffix>
<LucideChevronDown
class="size-4 shrink-0 text-ink-gray-4 ml-auto"
/>
</template>
</Select>
<p class="text-p-xs text-ink-gray-5">{{ roleDescription }}</p>
</div>
<Button
type="submit"
variant="solid"
@@ -102,13 +108,22 @@ import { useAuthStore } from "@/stores/auth";
import { capture } from "@/telemetry";
import { __ } from "@/translation";
import { handleInviteUserSuccess } from "@/utils";
import { Button, FormControl, Tooltip, createResource, toast } from "frappe-ui";
import {
Button,
FormControl,
Select,
Tooltip,
createResource,
toast,
} from "frappe-ui";
import { useOnboarding } from "frappe-ui/frappe";
import { computed, ref } from "vue";
import LucideChevronDown from "~icons/lucide/chevron-down";
const authStore = useAuthStore();
const { isAdmin, isManager } = authStore;
// @ts-expect-error
const { updateOnboardingStep } = useOnboarding("helpdesk");
const emails = ref("");
@@ -169,7 +184,8 @@ const roleDescription = computed(
const onSubmit = async () => {
if (emails.value.trim() === "") {
toast.error(__("At least one email required"));
toast.error(__("Please enter at least one valid email to send an invite."));
return;
}
await inviteByEmailResource.submit({
emails: emails.value,
+222 -211
View File
@@ -4,7 +4,7 @@
:description="__('Manage your profile information.')"
>
<template #content>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center justify-between gap-2 pt-1.5 pb-8">
<FileUploader
:fileTypes="['image/*']"
@success="
@@ -18,54 +18,72 @@
<div class="group relative !size-14">
<Avatar
class="!size-14"
:image="profile.userImage"
:label="profile.fullName"
:image="user.doc?.user_image"
:label="fullName"
/>
<component
:is="profile.userImage ? Dropdown : 'div'"
v-bind="
profile.userImage
? {
options: [
{
icon: 'upload',
label: profile.userImage
? __('Change image')
: __('Upload image'),
onClick: openFileSelector,
},
{
icon: 'trash-2',
label: __('Remove image'),
onClick: () => updateImage(null),
},
],
}
: { onClick: openFileSelector }
"
<Tooltip
:hoverDelay="0"
placement="bottom"
:text="profileTooltipText"
>
<div
class="z-1 absolute top-0 left-0 flex h-9 cursor-pointer items-center justify-center rounded-full bg-black bg-opacity-40 opacity-0 duration-300 ease-in-out group-hover:opacity-100 !size-14"
class="z-1 absolute top-0 left-0 flex h-9 cursor-pointer items-center justify-center rounded-full !size-14"
@click.stop="openFileSelector"
/>
<div
v-if="user.doc?.user_image"
class="z-1 size-4 absolute -top-1 -right-1 flex cursor-pointer items-center justify-center rounded-full bg-surface-white opacity-0 duration-300 ease-in-out group-hover:opacity-100 hover:bg-surface-gray-2 outline outline-black-overlay-50"
@click.stop="updateImage()"
@mouseenter="isHoveringRemove = true"
@mouseleave="isHoveringRemove = false"
>
<CameraIcon class="size-4 cursor-pointer text-white" />
<FeatherIcon
name="x"
class="size-3.5 cursor-pointer text-ink-gray-4"
/>
</div>
</component>
</Tooltip>
<div
v-if="uploading"
class="w-full h-full top-0 left-0 absolute bg-black bg-opacity-20 rounded-full flex items-center justify-center"
class="w-full h-full top-0 left-0 absolute bg-surface-gray-7 bg-opacity-20 rounded-full flex items-center justify-center"
>
<LoadingIndicator class="size-4" />
</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex flex-col">
<span
class="text-lg sm:text-xl !font-semibold text-ink-gray-8"
>{{ auth?.userName }}</span
>
<span class="text-p-sm text-ink-gray-6">{{
auth?.user
}}</span>
<div class="flex flex-col gap-1">
<div v-if="!editName" class="flex items-end gap-1">
<span
class="text-lg sm:text-xl !font-semibold text-ink-gray-8"
>
{{ user?.doc?.full_name }}
</span>
<Button
class="!px-1 !h-5"
variant="ghost"
@click="editFullName"
>
<EditIcon class="size-3.5" />
</Button>
</div>
<div v-else class="flex items-center gap-1">
<TextInput
ref="fullNameRef"
v-model="fullName"
@keydown.enter="isNameDirty ? save() : (editName = false)"
@keydown.esc.stop="editName = false"
/>
<Button
variant="outline"
icon="check"
:loading="user?.save?.loading"
:disabled="user?.save?.loading"
@click="isNameDirty ? save() : (editName = false)"
/>
</div>
<span class="text-p-sm text-ink-gray-6">
{{ user?.doc?.email }}
</span>
</div>
<ErrorMessage :message="__(_error)" />
</div>
@@ -73,77 +91,71 @@
</template>
</FileUploader>
</div>
<hr class="my-6" />
<div>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between h-7">
<div class="flex gap-2 items-center">
<div class="text-base font-semibold text-ink-gray-9">
{{ __("Account & Security") }}
</div>
<Badge
v-if="isAccountInfoDirty || isLanguageChanged"
:variant="'subtle'"
:theme="'orange'"
size="sm"
:label="__('Unsaved')"
/>
</div>
<Button
:label="__('Save')"
variant="solid"
class="transition-colors"
@click="onSave"
:loading="
setAgent.loading ||
saveLanguageResource.loading ||
saveTimezoneResource.loading
"
:disabled="
!isAccountInfoDirty && !isLanguageChanged && !isTimezoneChanged
"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mt-6">
<FormControl
class="w-full"
:label="__('First name')"
maxlength="40"
v-model="profile.firstName"
/>
<FormControl
class="w-full"
:label="__('Last name')"
maxlength="40"
v-model="profile.lastName"
/>
</div>
<div class="flex items-center justify-between mt-6">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">
{{ __("Password") }}
<span class="text-base font-semibold text-ink-gray-9">
{{ __("Account Info & Security") }}
</span>
<span class="text-p-sm text-ink-gray-6">{{
__("Change your account password for security.")
}}</span>
<UnsavedBadge :show="isDirty" />
</div>
<Button
icon-left="lock"
:label="__('Change Password')"
@click="showChangePasswordModal = true"
/>
<Transition name="fade">
<Button
variant="solid"
v-if="isDirty"
:label="__('Save')"
:loading="user?.save?.loading"
@click="save()"
/></Transition>
</div>
</div>
<div class="flex items-center justify-between mt-6">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">
{{ __("Emails & Signature") }}
</span>
<span class="text-p-sm text-ink-gray-6">
{{
__(
"Manage your account emails and email signature for communication."
)
}}
</span>
</div>
<Button
:label="__('Configure')"
@click="emit('updateStep', 'user-email-settings')"
/>
</div>
<div class="flex items-center justify-between mt-6">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">
{{ __("Password") }}
</span>
<span class="text-p-sm text-ink-gray-6">{{
__("Change your account password for security.")
}}</span>
</div>
<Button
icon-left="lock"
:label="__('Change Password')"
@click="showChangePasswordModal = true"
/>
</div>
<div>
<div class="flex items-center justify-between mt-6">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">
{{ __("Language") }}
</span>
<span class="text-p-sm text-ink-gray-6">{{
__("Change language of the application.")
}}</span>
<span class="text-p-sm text-ink-gray-6">
{{ __("Change language of the application.") }}
</span>
</div>
<Link
:model-value="language"
@update:modelValue="language = $event || auth.language"
:model-value="user.doc?.language"
@update:modelValue="updateLanguage"
doctype="Language"
class="w-40"
/>
@@ -153,9 +165,9 @@
<span class="text-base font-medium text-ink-gray-8">
{{ __("Timezone") }}
</span>
<span class="text-p-sm text-ink-gray-6">{{
__("Change timezone of the application.")
}}</span>
<span class="text-p-sm text-ink-gray-6">
{{ __("Change timezone of the application.") }}
</span>
</div>
<TimezoneControl label="Timezone" v-model="timezone" class="!w-40" />
</div>
@@ -175,21 +187,28 @@ import TimezoneControl from "@/components/TimezoneControl.vue";
import { useAuthStore } from "@/stores/auth";
import { __ } from "@/translation";
import { HDAgent } from "@/types/doctypes";
import { computed, nextTick, ref, watch, useTemplateRef } from "vue";
import {
Avatar,
Badge,
Button,
createResource,
Dropdown,
FileUploader,
LoadingIndicator,
toast,
createDocumentResource,
createResource,
} from "frappe-ui";
import { computed, ref } from "vue";
import CameraIcon from "~icons/lucide/camera";
import { disableSettingModalOutsideClick } from "../settingsModal";
import ChangePasswordModal from "./components/ChangePasswordModal.vue";
import { disableSettingModalOutsideClick } from "../settingsModal";
import EditIcon from "~icons/lucide/edit";
const emit = defineEmits(["updateStep"]);
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import Link from "@/components/frappe-ui/Link.vue";
import { HDAgent } from "@/types/doctypes";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
const auth = useAuthStore();
const profile = ref({
fullName: auth.userName,
@@ -197,130 +216,122 @@ const profile = ref({
firstName: auth.userFirstName,
lastName: auth.userLastName,
});
const showChangePasswordModal = ref(false);
const language = ref(auth.language);
const timezone = ref(auth.timezone);
const isLanguageChanged = computed(() => {
return language.value !== auth?.language;
},
});
const isTimezoneChanged = computed(() => {
return timezone.value !== auth?.timezone;
const { userId } = useAuthStore();
const user = createDocumentResource({ doctype: "User", name: userId });
const isHoveringRemove = ref(false);
const editName = ref(false);
const profileTooltipText = computed(() => {
if (isHoveringRemove.value) return __("Remove Photo");
return user.doc?.user_image ? __("Change Photo") : __("Upload Photo");
});
const isAccountInfoDirty = computed(() => {
const agentName = agentData.data?.agent_name?.split(" ");
if (!agentName) return false;
const isDirty =
profile.value.firstName !== agentName[0] ||
profile.value.lastName !== (agentName[1] || "");
if (isDirty) {
disableSettingModalOutsideClick.value = true;
} else {
disableSettingModalOutsideClick.value = false;
}
return isDirty;
const fullNameRef = useTemplateRef("fullNameRef");
const fullName = computed({
get: () => user.doc?.full_name ?? "",
set: (val) => {
if (!user.doc) return;
const [firstName, ...lastName] = val.split(" ");
user.doc.first_name = firstName;
user.doc.last_name = lastName.join(" ");
},
});
const agentData = createResource({
url: "frappe.client.get",
function editFullName() {
editName.value = true;
nextTick(() => fullNameRef.value?.el?.focus());
}
const isDirty = computed(() => {
if (!user.originalDoc) return false;
return user.doc?.time_zone !== user.originalDoc?.time_zone ||
user.doc?.language !== user.originalDoc?.language
? true
: false;
});
const isNameDirty = computed(() => {
return (
user.doc?.first_name !== user.originalDoc?.first_name ||
user.doc?.last_name !== user.originalDoc?.last_name
);
});
function save() {
refreshRequired.value =
user.doc?.language !== user.originalDoc?.language ||
user.doc?.time_zone !== user.originalDoc?.time_zone;
user.save.submit(null, {
onSuccess: () => {
editName.value = false;
toast.success(__("Profile updated successfully."));
if (refreshRequired.value) {
window.location.reload();
}
},
onError: (err: { message: string; messages: string[] }) => {
toast.error(err.message + ": " + err.messages[0]);
},
});
}
function updateImage(fileUrl = "") {
isHoveringRemove.value = false;
user.doc.user_image = fileUrl;
save();
}
function updateLanguage(val: string | null) {
if (!user.doc) return;
user.doc.language = val || user.originalDoc?.language;
}
function updateTimezone(val: { label: string; value: string } | null) {
if (!user.doc) return;
user.doc.time_zone = val?.value || user.originalDoc?.time_zone;
}
const timezoneOptions = ref([]);
const timezoneData = createResource({
url: "frappe.core.doctype.user.user.get_timezones",
auto: true,
makeParams() {
return {
doctype: "HD Agent",
name: auth.userId,
};
},
onSuccess: (data: HDAgent) => {
const fullName = data.agent_name.split(" ");
profile.value = {
fullName: data.agent_name,
firstName: fullName[0],
lastName: fullName[1] || "",
userImage: data.user_image,
};
onSuccess(data) {
timezoneOptions.value = data.timezones.map((tz) => ({
label: tz,
value: tz,
}));
},
});
const setAgent = createResource({
url: "frappe.client.set_value",
validate: () => {
if (!profile.value.firstName.trim()) {
return __("Please enter first name at least");
}
},
makeParams() {
return {
doctype: "HD Agent",
name: agentData.data?.name,
fieldname: {
agent_name: `${profile.value.firstName} ${profile.value.lastName}`,
user_image: profile.value.userImage,
},
};
},
onSuccess: () => {
auth.reloadUser();
agentData.reload();
toast.success(__("Profile updated successfully."));
const language = ref(null);
const timezone = ref(null);
const refreshRequired = ref(false);
watch(
() => user.doc,
(doc) => {
if (!doc) return;
if (!language.value) language.value = doc.language;
if (!timezone.value) timezone.value = doc.time_zone;
},
{ immediate: true }
);
watch(isDirty, (val) => {
disableSettingModalOutsideClick.value = val;
});
const saveLanguageResource = createResource({
url: "frappe.client.set_value",
makeParams() {
return {
doctype: "User",
name: auth.userId,
fieldname: {
language: language.value,
},
};
},
onSuccess() {
toast.success(__("Language updated successfully."));
setTimeout(() => {
window.location.reload(true);
}, 500);
},
});
const saveTimezoneResource = createResource({
url: "frappe.client.set_value",
makeParams() {
return {
doctype: "User",
name: auth.userId,
fieldname: {
time_zone: timezone.value,
},
};
},
onSuccess() {
toast.success(__("Timezone updated successfully."));
setTimeout(() => {
window.location.reload(true);
}, 500);
},
});
const onSave = () => {
if (isAccountInfoDirty.value) {
setAgent.submit();
}
if (isLanguageChanged.value) {
saveLanguageResource.submit();
}
if (isTimezoneChanged.value) {
saveTimezoneResource.submit();
}
};
const updateImage = (file: string | null) => {
profile.value.userImage = file;
setAgent.submit();
};
</script>
@@ -0,0 +1,18 @@
<template>
<Profile v-if="step === 'profile'" @updateStep="updateStep" />
<UserEmailSettings
v-else-if="step === 'user-email-settings'"
@updateStep="updateStep"
/>
</template>
<script setup>
import Profile from "./Profile.vue";
import UserEmailSettings from "./UserEmailSettings.vue";
import { ref } from "vue";
const step = ref("profile");
function updateStep(newStep) {
step.value = newStep;
}
</script>
@@ -0,0 +1,256 @@
<template>
<SettingsLayoutBase>
<template #title>
<div class="flex gap-1 items-center">
<Button
variant="ghost"
icon-left="chevron-left"
:label="__('Email Settings')"
size="md"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start"
@click="goBack"
/>
<Transition name="fade">
<Badge
v-if="isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/></Transition>
</div>
</template>
<template #header-actions>
<Transition name="fade">
<div v-if="isDirty">
<Button
variant="solid"
:label="__('Update')"
:loading="user?.save?.loading"
@click="update"
/></div
></Transition>
</template>
<template #content>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">
{{ __("Signature") }}
</span>
<span class="text-p-sm text-ink-gray-6">
{{ __("Manage your email signature.") }}
</span>
</div>
<TextEditor
editor-class="!prose-sm max-w-full overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded-b border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors -mt-0.5"
:content="user?.doc?.email_signature"
:placeholder="__('Write your email signature here.')"
:bubbleMenu="true"
:fixed-menu="true"
@change="(val) => (user.doc.email_signature = val)"
/>
</div>
<div class="flex flex-col gap-4 mt-6">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">
{{ __("Emails") }}
</span>
<span class="text-p-sm text-ink-gray-6">
{{
__(
"Switch between outgoing email accounts when sending emails from your configured accounts."
)
}}
</span>
</div>
<div>
<div
v-if="user.doc.user_emails?.length"
class="w-full border rounded-md mb-2 border-outline-gray-modals"
>
<div
class="grid grid-cols-[4fr_4fr_0.3fr] gap-2 px-4 py-3 text-sm font-medium text-ink-gray-5 border-b border-outline-gray-modals"
>
<span>{{ __("Email Account") }}</span>
<span>{{ __("Email") }}</span>
<span></span>
</div>
<div
v-for="e in user.doc.user_emails"
:key="e.name"
class="grid grid-cols-[4fr_4fr_0.3fr] gap-2 group items-center px-4 py-2.5 text-base border-b border-outline-gray-modals last:border-b-0"
>
<span class="text-ink-gray-8 font-medium truncate">
{{ e.email_account }}
</span>
<span class="text-ink-gray-6 truncate">{{ e.email_id }}</span>
<div class="group-hover:opacity-100 opacity-0 transition-opacity">
<Button
class="w-10"
variant="ghost"
:tooltip="__('Remove')"
icon="x"
@click.prevent="removeEmail(e)"
/>
</div>
</div>
</div>
<Autocomplete
value=""
:options="filteredEmails"
@change="(e) => addEmail(e)"
>
<template #target="{ togglePopover }">
<Button
class="!bg-surface-modal"
variant="outline"
:label="__('Add Email')"
iconLeft="plus"
@click="togglePopover()"
/>
</template>
<template #item-label="{ option }">
<div class="flex flex-col gap-1 text-ink-gray-9">
<div>{{ option.label }}</div>
<div class="text-ink-gray-4 text-sm">
{{ option.email }}
</div>
</div>
</template>
</Autocomplete>
</div>
</div>
</template>
</SettingsLayoutBase>
<ConfirmDialog
v-model="showConfirmDialog.show"
:title="showConfirmDialog.title"
:message="showConfirmDialog.message"
:onConfirm="showConfirmDialog.onConfirm"
:onCancel="
() => {
if (showConfirmDialog.onCancel) {
showConfirmDialog.onCancel();
} else {
showConfirmDialog.show = false;
}
}
"
/>
</template>
<script setup>
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import Autocomplete from "@/components/frappe-ui/Autocomplete.vue";
import {
Badge,
Button,
createDocumentResource,
createResource,
TextEditor,
toast,
} from "frappe-ui";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { computed, ref, watch } from "vue";
import { useAuthStore } from "@/stores/auth";
import { disableSettingModalOutsideClick } from "../settingsModal";
const { userId } = useAuthStore();
const user = createDocumentResource({ doctype: "User", name: userId });
const emit = defineEmits(["updateStep"]);
import { __ } from "@/translation";
import { normalize } from "@/utils";
const currentUserEmailInfo = createResource({
url: "helpdesk.api.auth.get_current_user_email_info",
cache: "current-user-email-info",
auto: true,
});
const filteredEmails = computed(() => {
if (!currentUserEmailInfo.data?.available_emails) return [];
const linkedEmails = user.doc.user_emails?.map((e) => e.email_id) || [];
return currentUserEmailInfo.data.available_emails
.map((doc) => ({
label: doc.name,
value: doc.name,
email: doc.email_id,
}))
.filter((e) => !linkedEmails.includes(e.email));
});
const isSignatureDirty = computed(() => {
return (
normalize(currentUserEmailInfo.data?.email_signature) !==
normalize(user?.doc?.email_signature)
);
});
const isUserEmailListDirty = computed(() => {
const emailIds = (list = []) => list.map((e) => e.email_id).sort();
return (
JSON.stringify(emailIds(currentUserEmailInfo.data?.outgoing_emails)) !==
JSON.stringify(emailIds(user.doc.user_emails))
);
});
const isDirty = computed(() => {
return isSignatureDirty.value || isUserEmailListDirty.value;
});
if (isDirty.value) {
disableSettingModalOutsideClick.value = true;
} else {
disableSettingModalOutsideClick.value = false;
}
function addEmail(email) {
if (!user.doc.user_emails) user.doc.user_emails = [];
user.doc.user_emails.push({
email_account: email.label,
email_id: email.email,
});
}
function removeEmail(email) {
user.doc.user_emails = user.doc.user_emails.filter(
(e) => e.email_id !== email.email_id
);
}
function update() {
user.save.submit(null, {
onSuccess: () => {
toast.success(__("Email settings updated successfully."));
currentUserEmailInfo.reload();
user.reload();
},
});
}
watch(isDirty, (val) => {
disableSettingModalOutsideClick.value = val;
});
const showConfirmDialog = ref({
show: false,
title: "",
message: "",
onConfirm: () => {},
});
const goBack = () => {
const confirmDialogInfo = {
show: true,
title: __("Unsaved changes"),
message: __(
"Are you sure you want to go back? Unsaved changes will be lost."
),
onConfirm: goBack,
};
if (isDirty.value && !showConfirmDialog.value.show) {
showConfirmDialog.value = confirmDialogInfo;
return;
}
showConfirmDialog.value.show = false;
emit("updateStep", "profile");
};
</script>
@@ -24,7 +24,7 @@
@input="savedRepliesSearchQuery = $event"
:placeholder="__('Search')"
type="text"
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
class="focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
@@ -51,9 +51,9 @@
</template>
</Button>
</template>
<template #item="{ item }">
<template #item-label="{ item }">
<button
class="group flex text-ink-gray-6 gap-4 h-7 w-full justify-between items-center rounded p-2 text-base hover:bg-surface-gray-3"
class="group flex text-ink-gray-6 gap-4 w-full justify-between items-center rounded text-base"
@click="item.onSelect"
>
<div class="flex items-center justify-between flex-1">
@@ -74,31 +74,20 @@
<template #content>
<div
v-if="savedRepliesListResource?.list?.loading"
class="flex items-center justify-center mt-12"
class="flex items-center justify-center h-[stretch] absolute w-[stretch] left-0 top-5.5"
>
<LoadingIndicator class="w-4" />
</div>
<div
<EmptyState
v-if="
!savedRepliesListResource?.list?.loading &&
!savedRepliesListResource?.data?.length
"
class="flex flex-col items-center justify-center gap-4 grow"
>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<SavedReplyIcon class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No saved replies found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{ __("Add one to get started.") }}
</div>
</div>
</div>
variant="badge"
:icon="SavedReplyIcon"
title="No saved replies found"
description="Add one to get started."
/>
<div
v-if="
!savedRepliesListResource?.list?.loading &&
@@ -107,7 +96,7 @@
class="-ml-2"
>
<div
class="grid grid-cols-12 items-center gap-3 text-sm text-gray-600 ml-2"
class="grid grid-cols-12 items-center gap-3 text-sm text-ink-gray-5 ml-2"
>
<div class="col-span-7">{{ __("Title") }}</div>
<div class="col-span-2">{{ __("Owner") }}</div>
@@ -119,7 +108,7 @@
:key="savedReply.name"
>
<div
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded"
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-surface-menu-bar rounded"
>
<div
@click="
@@ -128,7 +117,7 @@
data: savedReply,
}
"
class="w-full px-2 flex flex-col justify-center h-12.5 col-span-7"
class="w-full px-2 flex flex-col justify-center h-12.5 col-span-7 min-w-0"
>
<div
class="text-base text-ink-gray-7 font-medium w-full truncate"
@@ -137,7 +126,7 @@
</div>
</div>
<div
class="flex items-center gap-1.5 text-sm text-ink-gray-7 truncate col-span-2"
class="flex items-center gap-1.5 text-sm text-ink-gray-7 col-span-2 min-w-0"
>
<Avatar
:label="
@@ -145,8 +134,11 @@
"
:image="getUser(savedReply.owner)?.user_image"
size="xs"
class="shrink-0"
/>
{{ getUser(savedReply.owner)?.full_name }}
<span class="truncate">{{
getUser(savedReply.owner)?.full_name
}}</span>
</div>
<div
class="flex justify-between items-center w-full pr-2 col-span-3"
@@ -220,6 +212,7 @@ import { computed, inject, ref, Ref, watch } from "vue";
import { __ } from "@/translation";
import { ConfirmDelete } from "@/utils";
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
import EmptyState from "@/components/EmptyState.vue";
import { activeFilter } from "./savedReplies";
import { useUserStore } from "../../../stores/user";
import UserIcon from "~icons/lucide/user";
@@ -10,13 +10,7 @@
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
/>
<Badge
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved changes')"
v-if="isDirty"
/>
<UnsavedBadge :show="isDirty" />
</div>
</template>
<template #header-actions>
@@ -51,7 +45,7 @@
<template #content>
<div
v-if="getSavedReplyData.loading"
class="flex items-center justify-center mt-12"
class="flex items-center justify-center h-[stretch] absolute w-[stretch] left-0 top-5.5"
>
<LoadingIndicator class="w-4" />
</div>
@@ -74,6 +68,7 @@
v-model="savedReplyData.scope"
:options="scopeDropdownOptions"
required
class="w-full"
>
<template #prefix>
<component
@@ -81,7 +76,7 @@
class="size-4 text-ink-gray-9"
/>
</template>
<template #option="{ option }">
<template #label="{ option }">
<div class="flex gap-2 items-center cursor-pointer">
<component :is="option.icon" class="size-4 text-ink-gray-9" />
<span>
@@ -102,6 +97,7 @@
v-model="savedReplyData.teams"
:placeholder="__('Select teams')"
@update:modelValue="validateData('teams')"
class="w-full"
/>
<div class="text-xs text-ink-gray-5 cursor-default">
{{ __("Restrict visibility to these teams") }}
@@ -176,6 +172,7 @@ import { useConfigStore } from "@/stores/config";
import { useAuthStore } from "@/stores/auth";
import { FieldAutocomplete } from "../../../tiptap-extensions";
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
import UserIcon from "~icons/lucide/user";
import UsersIcon from "~icons/lucide/users";
import GlobeIcon from "~icons/lucide/globe";
@@ -268,6 +265,9 @@ const getTeamsListResource = createListResource({
doctype: "HD Team",
auto: true,
fields: ["name"],
filters: {
disabled: 0,
},
start: 0,
pageLength: 999,
transform: (data: Array<Team>) => {
@@ -33,7 +33,7 @@
/>
<div
v-if="getResponsePreviewResource.loading"
class="absolute top-0 right-0 flex items-center justify-center size-full rounded-md bg-black/20"
class="absolute top-0 right-0 flex items-center justify-center size-full rounded-md bg-surface-gray-7/20"
>
<LoadingIndicator class="size-4" />
</div>
@@ -7,7 +7,7 @@
</h1>
</slot>
<slot name="description">
<p class="text-p-sm text-gray-700 max-w-md text-ink-gray-6">
<p class="text-p-sm max-w-md text-ink-gray-6">
{{ description }}
</p>
</slot>
+15 -10
View File
@@ -5,21 +5,24 @@
:disableOutsideClickToClose="disableSettingModalOutsideClick"
>
<template #body>
<div class="flex z-50" :style="{ height: 'calc(100vh - 8rem)' }">
<div
class="flex z-50 overflow-hidden"
:style="{ height: 'calc(100vh - 8rem)' }"
>
<div
class="flex w-52 shrink-0 flex-col bg-gray-50 p-1 overflow-y-auto hide-scrollbar"
class="flex-col rounded-l-lg w-56 shrink-0 pl-1 py-1 bg-surface-menu-bar overflow-y-auto hide-scrollbar"
>
<h1
class="h-7.5 px-2 py-[7px] my-[3px] flex cursor-pointer gap-1.5 text-base text-ink-gray-5 transition-all duration-300 ease-in-out truncate"
class="h-7.5 px-2 py-[7px] my-[3px] flex cursor-pointer gap-1.5 text-xs font-medium text-ink-gray-5 transition-all duration-300 ease-in-out sticky top-0 z-10 bg-surface-menu-bar"
>
{{ __("My Settings") }}
{{ __("Account") }}
</h1>
<div v-for="tab in tabs" class="last:mb-2">
<div v-if="!tab.noborder" class="mx-2 my-2.5"></div>
<div
v-if="!tab.hideLabel"
class="h-7.5 px-2 py-[7px] my-[3px] flex cursor-pointer gap-1.5 text-base text-ink-gray-5 transition-all duration-300 ease-in-out"
class="h-7.5 px-2 py-[7px] my-[3px] flex cursor-pointer gap-1.5 text-xs font-medium text-ink-gray-5 transition-all duration-300 ease-in-out sticky top-0 z-10 bg-surface-menu-bar"
>
<Tooltip :text="__(tab.label)" placement="right">
<span class="truncate">{{ __(tab.label) }}</span>
@@ -33,17 +36,17 @@
class="flex h-7 w-full items-center gap-2 rounded px-2 py-[7px]"
:class="[
activeTab?.label == item.label
? 'bg-white shadow-sm'
: 'hover:bg-gray-100',
? 'bg-surface-selected shadow-sm'
: 'hover:bg-surface-gray-2',
]"
@click="() => onTabChange(item)"
>
<component
:is="item.icon"
class="h-4 w-4 text-gray-700 shrink-0"
class="h-4 w-4 text-ink-gray-7 shrink-0"
/>
<Tooltip :text="__(item.label)" placement="right">
<span class="text-p-sm text-gray-800 truncate">
<span class="text-p-sm text-ink-gray-8 truncate">
{{ __(item.label) }}
</span>
</Tooltip>
@@ -51,7 +54,9 @@
</nav>
</div>
</div>
<div class="flex flex-1 flex-col bg-surface-modal max-w-[816px]">
<div
class="flex flex-1 flex-col bg-surface-modal max-w-[816px] overflow-hidden relative"
>
<component
:is="activeTab.component"
v-if="activeTab"
@@ -23,16 +23,16 @@
<template #target="{ togglePopover }" class="w-max">
<div
@click="togglePopover()"
class="w-full bg-gray-100 rounded p-1.5 px-2 text-base text-gray-800"
class="w-full bg-surface-gray-2 rounded p-1.5 px-2 text-base text-ink-gray-8"
>
<div v-if="priorityData.response_time">
{{ formatTimeHMS(priorityData.response_time) }}
</div>
<div v-else class="text-gray-500">Select time</div>
<div v-else class="text-ink-gray-4">Select time</div>
</div>
</template>
<template #body>
<div class="absolute bg-white top-2">
<div class="absolute bg-surface-white top-2">
<DurationPicker
v-model="priorityData.response_time"
:options="{ seconds: false }"
@@ -47,16 +47,16 @@
<template #target="{ togglePopover }" class="w-max">
<div
@click="togglePopover()"
class="w-full bg-gray-100 rounded p-1.5 px-2 text-base text-gray-800"
class="w-full bg-surface-gray-2 rounded p-1.5 px-2 text-base text-ink-gray-8"
>
<div v-if="priorityData.resolution_time">
{{ formatTimeHMS(priorityData.resolution_time) }}
</div>
<div v-else class="text-gray-500">Select time</div>
<div v-else class="text-ink-gray-4">Select time</div>
</div>
</template>
<template #body>
<div class="absolute bg-white top-2">
<div class="absolute bg-surface-white top-2">
<DurationPicker
v-model="priorityData.resolution_time"
:options="{ seconds: false }"
@@ -44,7 +44,7 @@
value: 'Sunday',
},
]"
:class="{ 'border-red-500': errors.workday }"
:class="{ 'border-outline-red-3': errors.workday }"
@blur="validateField('workday')"
/>
<ErrorMessage :message="errors.workday" class="mt-2" />
@@ -58,7 +58,7 @@
placeholder="Start Time"
label="Start Time"
v-model="workDayData.start_time"
:class="{ 'border-red-500': errors.start_time }"
:class="{ 'border-outline-red-3': errors.start_time }"
@blur="validateField('start_time')"
/>
<ErrorMessage :message="errors.start_time" class="mt-2" />
@@ -72,7 +72,7 @@
placeholder="End Time"
label="End Time"
v-model="workDayData.end_time"
:class="{ 'border-red-500': errors.end_time }"
:class="{ 'border-outline-red-3': errors.end_time }"
@blur="validateTimeRange"
/>
<ErrorMessage :message="errors.end_time" class="mt-2" />
@@ -7,7 +7,7 @@
/>
<div
v-if="props.conditions.length == 0"
class="flex p-4 items-center cursor-pointer justify-center gap-2 text-sm border border-gray-300 text-gray-600 rounded-md"
class="flex p-4 items-center cursor-pointer justify-center gap-2 text-sm border border-outline-gray-2 text-ink-gray-5 rounded-md"
@click="props.conditions.push(['', '', ''])"
>
<FeatherIcon name="plus" class="h-4" />
@@ -39,7 +39,7 @@
@input="slaSearchQuery = $event"
:placeholder="__('Search')"
type="text"
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
class="focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
@@ -1,7 +1,7 @@
<template>
<div
v-if="slaPolicyList.list.loading && !slaPolicyList.list.data"
class="flex items-center justify-center mt-12"
class="flex items-center justify-center h-[stretch] absolute w-[stretch] left-0 top-5.5"
>
<LoadingIndicator class="w-4" />
</div>
@@ -26,7 +26,7 @@
</div>
<div v-else class="-ml-2">
<div
class="grid grid-cols-6 items-center gap-3 text-sm text-gray-600 ml-2"
class="grid grid-cols-6 items-center gap-3 text-sm text-ink-gray-5 ml-2"
>
<div class="col-span-5">
{{ __("Policy name") }}
@@ -1,6 +1,6 @@
<template>
<div
class="grid grid-cols-6 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded"
class="grid grid-cols-6 items-center gap-4 cursor-pointer hover:bg-surface-menu-bar rounded"
>
<div
@click="slaActiveScreen = { screen: 'view', data: data, fetchData: true }"
@@ -10,13 +10,7 @@
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
/>
<Badge
:variant="'subtle'"
:theme="'orange'"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
<UnsavedBadge :show="isDirty" />
</div>
</template>
<template #header-actions>
@@ -43,7 +37,7 @@
<template #content>
<div
v-if="slaData.loading"
class="flex items-center h-full justify-center"
class="flex items-center justify-center h-[stretch] absolute w-[stretch] left-0 top-5.5"
>
<LoadingIndicator class="w-4" />
</div>
@@ -106,7 +100,7 @@
</template>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
class="text-sm text-ink-gray-6 p-2 bg-surface-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ slaData.condition }}</code>
</div>
@@ -116,7 +110,7 @@
</div>
<div class="mt-5" v-if="!slaData.default_sla">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-md p-3 py-4"
v-if="!useNewUI"
>
<span class="text-p-sm">
@@ -299,6 +293,7 @@ import { disableSettingModalOutsideClick } from "../settingsModal";
import { useOnboarding } from "frappe-ui/frappe";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
import { SlaPolicyListResourceSymbol } from "@/types";
import { HDServiceLevelAgreement } from "@/types/doctypes";
@@ -1,5 +1,5 @@
<template>
<div class="rounded-md border px-2 border-gray-300 text-sm">
<div class="rounded-md border px-2 border-outline-gray-2 text-sm">
<div
class="grid p-2 px-4 items-center"
:style="{
@@ -10,7 +10,7 @@
<div
v-for="column in columns"
:key="column.key"
class="text-gray-600 overflow-hidden whitespace-nowrap text-ellipsis"
class="text-ink-gray-5 overflow-hidden whitespace-nowrap text-ellipsis"
:class="{
'ml-2':
column.key === 'priority' ||
@@ -19,7 +19,7 @@
}"
>
{{ column.label }}
<span v-if="column.isRequired" class="text-red-500">*</span>
<span v-if="column.isRequired" class="text-ink-red-3">*</span>
</div>
</div>
<hr v-if="slaData.priorities?.length !== 0" />
@@ -32,7 +32,7 @@
/>
<div
v-if="slaData.priorities?.length === 0"
class="text-center p-4 text-gray-600"
class="text-center p-4 text-ink-gray-5"
>
No priorities in the list
</div>
@@ -61,23 +61,26 @@
</template>
<script setup lang="ts">
import { Button, createResource, toast } from "frappe-ui";
import SlaPriorityListItem from "./SlaPriorityListItem.vue";
import { computed, provide, reactive } from "vue";
import {
slaActiveScreen,
slaData,
slaDataErrors,
validateSlaData,
} from "@/stores/sla";
import { watchDebounced } from "@vueuse/core";
import { getGridTemplateColumnsForTable } from "@/utils";
import { watchDebounced } from "@vueuse/core";
import { Button, createResource, toast } from "frappe-ui";
import { computed, provide, reactive } from "vue";
import SlaPriorityListItem from "./SlaPriorityListItem.vue";
createResource({
url: "frappe.client.get_list",
params: {
doctype: "HD Ticket Priority",
fields: ["name"],
filters: {
disabled: 0,
},
order_by: "integer_value desc",
},
auto: true,
@@ -25,13 +25,13 @@
<template #target="{ togglePopover }">
<div
@click="togglePopover()"
class="min-h-7 w-full cursor-pointer select-none leading-5 p-1 px-2 hover:bg-gray-200 rounded"
class="min-h-7 w-full cursor-pointer select-none leading-5 p-1 px-2 hover:bg-surface-gray-3 rounded"
>
{{ formatTimeHMS(props.row[column.key]) }}
</div>
</template>
<template #body>
<div class="absolute bg-white top-2">
<div class="absolute bg-surface-white top-2">
<DurationPicker v-model="props.row[column.key]" />
</div>
</template>
@@ -1,5 +1,5 @@
<template>
<div class="rounded-md border px-2 border-gray-300 text-sm">
<div class="rounded-md border px-2 border-outline-gray-2 text-sm">
<div
class="grid p-2 px-4 items-center"
:style="{
@@ -10,13 +10,13 @@
<div
v-for="column in columns"
:key="column.key"
class="text-gray-600 overflow-hidden whitespace-nowrap text-ellipsis"
class="text-ink-gray-5 overflow-hidden whitespace-nowrap text-ellipsis"
:class="{
'ml-2': column.key === 'workday',
}"
>
{{ column.label }}
<span v-if="column.isRequired" class="text-red-500">*</span>
<span v-if="column.isRequired" class="text-ink-red-3">*</span>
</div>
</div>
<hr v-if="slaData.support_and_resolution?.length !== 0" />
@@ -29,7 +29,7 @@
/>
<div
v-if="slaData.support_and_resolution?.length === 0"
class="text-center p-4 text-gray-600"
class="text-center p-4 text-ink-gray-5"
>
No workdays in the list
</div>
+27 -15
View File
@@ -10,23 +10,25 @@
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
/>
<Badge
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
<UnsavedBadge :show="isDirty" />
</div>
</template>
<template #header-actions>
<Button
:label="__('Save')"
variant="solid"
@click="saveTeam()"
:disabled="!isDirty"
:loading="teamsList.insert.loading"
/>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 cursor-pointer">
<Switch v-model="teamData.enabled" />
<span class="text-sm text-ink-gray-7 font-medium">
{{ __("Enabled") }}
</span>
</div>
<Button
:label="__('Save')"
variant="solid"
@click="saveTeam()"
:disabled="!isDirty"
:loading="teamsList.insert.loading"
/>
</div>
</template>
<template #content>
<div class="flex flex-col gap-4">
@@ -65,7 +67,15 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref } from "vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { Badge, ErrorMessage, FormControl, FormLabel, toast } from "frappe-ui";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
import {
Badge,
ErrorMessage,
FormControl,
FormLabel,
Switch,
toast,
} from "frappe-ui";
import { __ } from "@/translation";
import AgentSelector from "./components/AgentSelector.vue";
import { useAgentStore } from "@/stores/agent";
@@ -94,6 +104,7 @@ const showConfirmDialog = ref({
const teamData = ref({
name: "",
agents: [],
enabled: true,
});
const errors = ref({
@@ -132,6 +143,7 @@ const saveTeam = () => {
{
team_name: teamData.value.name,
users: teamData.value.agents.map((agent) => ({ user: agent })),
disabled: !teamData.value.enabled,
},
{
onSuccess: (data) => {
@@ -10,6 +10,7 @@
<template #actions>
<Button
variant="solid"
class="flex w-full"
@click="renameTeam"
:loading="renameTeamResource.loading"
:disabled="teamName == dialog.teamName || teamName.trim() == ''"
+85 -154
View File
@@ -15,13 +15,21 @@
</div>
</template>
<template #header-actions>
<Dropdown placement="right" :options="options">
<Button variant="ghost">
<template #icon>
<LucideMoreHorizontal class="h-4 w-4" />
</template>
</Button>
</Dropdown>
<div class="flex items-center gap-4">
<div class="flex items-center justify-between gap-2 cursor-pointer">
<Switch v-model="teamEnabled" v-if="!team.loading" />
<span class="text-sm text-ink-gray-7 font-medium">
{{ __("Enabled") }}
</span>
</div>
<Dropdown placement="right" :options="options">
<Button variant="ghost">
<template #icon>
<LucideMoreHorizontal class="h-4 w-4" />
</template>
</Button>
</Dropdown>
</div>
</template>
<template #header-bottom>
<!-- Add member -->
@@ -52,7 +60,9 @@
</template>
<template #content>
<div class="w-full h-full" v-if="teamMembers?.length > 0">
<div class="grid grid-cols-8 items-center gap-3 text-sm text-gray-600">
<div
class="grid grid-cols-8 items-center gap-3 text-sm text-ink-gray-5"
>
<div class="col-span-6 text-p-sm">
{{ __("Members ({0})", teamMembers.length) }}
</div>
@@ -64,6 +74,7 @@
<AgentCard :agent="member" class="!py-0">
<template #right>
<Dropdown
v-if="teamMembers.length > 1"
:options="memberDropdownOptions(member)"
placement="right"
>
@@ -100,40 +111,15 @@
</div>
</template>
</SettingsLayoutBase>
<Dialog v-model="showDelete" :options="{ title: 'Delete team' }">
<template #body-content>
<p class="text-p-base text-ink-gray-7">
{{
__(
"Are you sure you want to delete this team? This action cannot be reversed!"
)
}}
</p>
<Button
variant="solid"
class="mt-4 float-right"
:label="__('Confirm')"
theme="red"
@click="
() => {
team.delete.submit();
showDelete = false;
emit('update:step', 'team-list');
}
"
/>
</template>
</Dialog>
<Dialog v-model="showRename" :options="renameDialogOptions">
<template #body-content>
<FormControl
v-model="_teamName"
:label="__('Title')"
:placeholder="__('Product Experts')"
maxlength="50"
/>
</template>
</Dialog>
<RenameTeamModal
v-model="showRename"
@onRename="
() => {
teamsList.reload();
emit('update:step', 'team-list');
}
"
/>
</template>
<script setup lang="ts">
@@ -148,13 +134,12 @@ import { ConfirmDelete } from "@/utils";
import {
Button,
createDocumentResource,
createResource,
Dialog,
Dropdown,
Switch,
toast,
Tooltip,
} from "frappe-ui";
import { computed, h, inject, markRaw, onMounted, ref } from "vue";
import RenameTeamModal from "./RenameTeamModal.vue";
import LucideLock from "~icons/lucide/lock";
import Settings from "~icons/lucide/settings-2";
import LucideUnlock from "~icons/lucide/unlock";
@@ -179,7 +164,6 @@ const teamsList = inject(TeamListResourceSymbol);
const { teamRestrictionApplied } = useConfigStore();
const invitees = ref<string[]>([]);
const _teamName = ref(props.teamName);
const team = createDocumentResource({
doctype: "HD Team",
name: props.teamName,
@@ -192,6 +176,31 @@ const team = createDocumentResource({
},
});
const teamEnabled = computed({
get() {
return !team.doc?.disabled;
},
set(value: boolean) {
if (!team.doc) return;
team.doc.disabled = !value;
team.setValue.submit(
{
disabled: !value,
},
{
onSuccess: () => {
toast.success(
value
? __("Team enabled successfully.")
: __("Team disabled successfully.")
);
team.reload();
},
}
);
},
});
const ignoreRestrictions = computed({
get() {
return !!team.doc?.ignore_restrictions;
@@ -231,52 +240,14 @@ function addMember(users: string[]) {
invitees.value = [];
}
const showRename = ref(false);
const showRename = ref({
show: false,
teamName: props.teamName,
});
const renameDialogOptions = {
title: __("Rename team"),
message: __("Enter the new name for the team"),
actions: [
{
label: __("Confirm"),
variant: "solid",
loading: team.loading,
onClick: ({ close }) => {
renameTeam(close);
},
},
],
};
const isConfirmingTeamDelete = ref(false);
function renameTeam(close) {
const r = createResource({
url: "frappe.client.rename_doc",
makeParams() {
return {
doctype: "HD Team",
old_name: props.teamName,
new_name: _teamName.value,
};
},
validate(params) {
if (!params.new_name) return __("New title is required");
if (params.new_name === params.old_name)
return __("New and old title cannot be same");
},
onSuccess() {
teamsList.reload();
toast.success(__("Team renamed successfully."));
close();
emit("update:step", "team-list");
},
});
r.submit();
}
const showDelete = ref(false);
const options = [
const options = computed(() => [
{
label: __("View Assignment rule"),
icon: markRaw(h(Settings, { class: "rotate-90" })),
@@ -291,73 +262,33 @@ const options = [
{
label: __("Rename"),
icon: "edit-3",
onClick: () => (showRename.value = !showRename.value),
onClick: () => {
showRename.value = { show: true, teamName: props.teamName };
},
},
{
condition: () => teamRestrictionApplied,
label: team.doc?.ignore_restrictions
? __("Disable Bypass Restrictions")
: __("Enable Bypass Restrictions"),
component: () =>
h(
Tooltip,
...(teamRestrictionApplied
? [
{
text: ignoreRestrictions.value
? __(
"Members of this team will see the tickets assigned to this team only"
)
: __(
"Members of this team will be able to see the tickets assigned to all the teams"
),
label: ignoreRestrictions.value
? __("Access only this team's tickets")
: __("Access all team tickets"),
icon: markRaw(ignoreRestrictions.value ? LucideLock : LucideUnlock),
onClick: () => {
ignoreRestrictions.value = !ignoreRestrictions.value;
toast.success(
ignoreRestrictions.value
? __("Team can now access all tickets.")
: __("Team will only see their own tickets.")
);
},
},
{
default: () => [
//create a div with 2 columns, one for icon and one for label
h(
"div",
{
class:
"flex items-center gap-2 p-2 cursor-pointer hover:bg-gray-100 rounded",
onClick: () =>
(ignoreRestrictions.value = !ignoreRestrictions.value),
},
[
h(team.doc?.ignore_restrictions ? LucideLock : LucideUnlock, {
class: "h-4 w-4 text-gray-700",
stroke: "currentColor",
"aria-hidden": "true",
}),
h(
"span",
{
class: "whitespace-nowrap text-ink-gray-7 text-p-base",
},
[
team.doc?.ignore_restrictions
? __("Access only this team's tickets")
: __("Access all team tickets"),
]
),
]
),
],
}
),
},
{
label: __("Delete"),
component: h(Button, {
label: __("Delete"),
variant: "ghost",
iconLeft: "trash-2",
theme: "red",
style: "width: 100%; justify-content: flex-start;",
onClick: () => {
showDelete.value = !showDelete.value;
},
}),
},
];
]
: []),
...ConfirmDelete({
isConfirmingDelete: isConfirmingTeamDelete,
onConfirmDelete: () => team.delete.submit(),
}),
]);
const isConfirmingDelete = ref(false);
@@ -29,7 +29,7 @@ const teamsSearchQuery = ref("");
const teams = createListResource({
doctype: "HD Team",
cache: ["Teams"],
fields: ["name"],
fields: ["name", "disabled"],
auto: true,
orderBy: "modified desc",
start: 0,
@@ -24,7 +24,7 @@
@input="teamsSearchQuery = $event"
:placeholder="__('Search')"
type="text"
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
class="focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
@@ -44,21 +44,22 @@
v-if="!teams.loading && teams.data?.length > 0"
class="w-full h-full -ml-2"
>
<div class="flex text-sm text-gray-600">
<div class="ml-2">{{ __("Team name") }}</div>
<div class="flex text-sm text-ink-gray-5">
<p class="ml-2">{{ __("Team name") }}</p>
</div>
<hr class="mx-2 mt-2" />
<div v-for="(team, index) in teams.data" :key="team.name">
<div
class="flex items-center cursor-pointer hover:bg-gray-50 rounded h-12.5"
class="flex items-center cursor-pointer hover:bg-surface-menu-bar rounded h-12.5"
>
<div
class="w-full py-3 pl-2"
class="w-full py-3 pl-2 flex gap-1 items-center"
@click="() => emit('update:step', 'team-edit', team.name)"
>
<div class="text-base text-ink-gray-7 font-medium">
<p class="text-base text-ink-gray-7 font-medium">
{{ team.name }}
</div>
</p>
<Badge :label="__('Disabled')" v-if="team.disabled" />
</div>
<div class="flex justify-between items-center pr-2">
<div>
@@ -99,28 +100,17 @@
/>
</div>
<!-- Empty State -->
<div
<EmptyState
v-if="!teams.loading && !teams.data?.length"
class="flex flex-col items-center justify-center gap-4 h-full"
>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<AgentIcon class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No team found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{
teamsSearchQuery.length
? __("Change your search terms to find teams.")
: __("Add one to get started.")
}}
</div>
</div>
</div>
variant="badge"
:icon="AgentIcon"
title="No team found"
:description="
teamsSearchQuery.length
? 'Change your search terms to find teams.'
: 'Add one to get started.'
"
/>
</template>
</SettingsLayoutBase>
<NewTeamModal
@@ -135,15 +125,17 @@
</template>
<script setup lang="ts">
import EditIcon from "@/components/icons/EditIcon.vue";
import AgentIcon from "@/components/icons/AgentIcon.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import EmptyState from "@/components/EmptyState.vue";
import { __ } from "@/translation";
import { TeamListResourceSymbol } from "@/types";
import { ConfirmDelete } from "@/utils";
import { Dropdown, Input, toast } from "frappe-ui";
import { inject, markRaw, Ref, ref, watch } from "vue";
import NewTeamModal from "../NewTeamModal.vue";
import { ConfirmDelete } from "@/utils";
import EditIcon from "@/components/icons/EditIcon.vue";
import RenameTeamModal from "./RenameTeamModal.vue";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { TeamListResourceSymbol } from "@/types";
interface E {
(event: "update:step", step: string, team: string): void;
@@ -152,7 +144,7 @@ interface E {
const emit = defineEmits<E>();
const teamsSearchQuery = inject<Ref>("teamsSearchQuery");
const teams = inject(TeamListResourceSymbol);
const teams = inject(TeamListResourceSymbol)!;
const showForm = ref(false);
const showRename = ref({
show: false,
@@ -12,16 +12,7 @@
@click="goBack"
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0 !pl-0 -ml-1.5"
/>
<Badge
:class="[
isDirty.twilio || isDirty.exotel || isDirty.telephonyAgent
? 'opacity-100'
: 'opacity-0',
]"
:label="__('Unsaved')"
theme="orange"
variant="subtle"
/>
<UnsavedBadge :show="isDirty.exotel" />
</div>
</template>
<template #header-actions>
@@ -30,14 +21,9 @@
theme="gray"
variant="solid"
@click="save"
:disabled="
!isDirty.twilio && !isDirty.exotel && !isDirty.telephonyAgent
"
:loading="
twilio.save.loading ||
exotel.save.loading ||
telephonyAgent.save.loading
"
v-if="isDirty.exotel"
:disabled="!isDirty.exotel"
:loading="exotel.save.loading"
/>
</template>
<template #content>
@@ -158,6 +144,7 @@ import { useAuthStore } from "@/stores/auth";
import { useTelephonyStore } from "@/stores/telephony";
import { disableSettingModalOutsideClick } from "../settingsModal";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { __ } from "@/translation";
@@ -5,40 +5,37 @@
<h1 class="text-lg font-semibold text-ink-gray-8">
{{ __("Telephony") }}
</h1>
<Badge
:class="[
isDirty.twilio || isDirty.exotel || isDirty.telephonyAgent
? 'opacity-100'
: 'opacity-0',
]"
:label="__('Unsaved')"
theme="orange"
variant="subtle"
<UnsavedBadge
:show="isDirty.twilio || isDirty.exotel || isDirty.telephonyAgent"
/>
</div>
</template>
<template #header-actions>
<Button
:label="__('Save')"
theme="gray"
variant="solid"
@click="save"
:disabled="
!isDirty.twilio && !isDirty.exotel && !isDirty.telephonyAgent
"
:loading="
twilio.save.loading ||
exotel.save.loading ||
telephonyAgent.save.loading
"
/>
<Transition name="fade">
<div v-if="isDirty.twilio || isDirty.exotel || isDirty.telephonyAgent">
<Button
:label="__('Save')"
theme="gray"
variant="solid"
@click="save"
:disabled="
!isDirty.twilio && !isDirty.exotel && !isDirty.telephonyAgent
"
:loading="
twilio.save.loading ||
exotel.save.loading ||
telephonyAgent.save.loading
"
/>
</div>
</Transition>
</template>
<template #content>
<div class="-ml-2 grow">
<div class="flex-1 flex flex-col">
<!-- General -->
<div
class="flex items-center justify-between gap-8 py-3 hover:bg-gray-50 rounded px-2"
class="flex items-center justify-between gap-8 py-3 hover:bg-surface-menu-bar rounded px-2"
>
<div class="flex flex-col">
<div class="text-p-base font-medium text-ink-gray-7 truncate">
@@ -67,7 +64,7 @@
<div class="h-px border-t mx-2 border-outline-gray-modals" />
<div
class="flex items-center justify-between py-3 cursor-pointer rounded hover:bg-gray-50 px-2"
class="flex items-center justify-between py-3 cursor-pointer rounded hover:bg-surface-menu-bar px-2"
@click="emit('updateStep', 'twilio-settings')"
>
<div class="flex flex-col">
@@ -88,7 +85,7 @@
<div class="h-px border-t mx-2 border-outline-gray-modals" />
<div
class="flex items-center justify-between py-3 cursor-pointer rounded hover:bg-gray-50 px-2"
class="flex items-center justify-between py-3 cursor-pointer rounded hover:bg-surface-menu-bar px-2"
@click="emit('updateStep', 'exotel-settings')"
>
<div class="flex flex-col">
@@ -135,6 +132,7 @@ import { useTelephonyStore } from "@/stores/telephony";
import { disableSettingModalOutsideClick } from "../settingsModal";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
const auth = useAuthStore();
const telephonyStore = useTelephonyStore();
@@ -12,12 +12,7 @@
@click="goBack"
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0 !pl-0 -ml-1.5"
/>
<Badge
:class="[isDirty.twilio ? 'opacity-100' : 'opacity-0']"
:label="__('Unsaved')"
theme="orange"
variant="subtle"
/>
<UnsavedBadge :show="isDirty.twilio" />
</div>
</template>
<template #header-actions>
@@ -26,6 +21,7 @@
theme="gray"
variant="solid"
@click="save"
v-if="isDirty.twilio"
:disabled="!isDirty.twilio"
:loading="twilio.save.loading"
/>
@@ -130,6 +126,7 @@
<script setup>
import Password from "@/components/Password.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import {
Button,
@@ -1,4 +1,5 @@
export const isDocDirty = (doc: any, originalDoc: any) => {
if (!doc || !originalDoc) return false;
return JSON.stringify(doc) !== JSON.stringify(originalDoc);
};
@@ -20,12 +20,12 @@ import TelephonyPage from "./Telephony/TelephonyPage.vue";
import { EmailNotifications } from "./EmailNotifications";
import { __ } from "@/translation";
import SavedReplies from "./SavedReplies/SavedReplies.vue";
import Profile from "./Profile/Profile.vue";
import { Avatar } from "frappe-ui";
import { useAuthStore } from "@/stores/auth";
import General from "./General/General.vue";
import SettingsGear from "~icons/lucide/settings";
import SavedReplyIcon from "../icons/SavedReplyIcon.vue";
import ProfilePage from "./Profile/ProfilePage.vue";
export const showSettingsModal = ref(false);
@@ -45,8 +45,8 @@ export const tabs = computed(() => {
label: auth.userName,
size: "xs",
}),
component: markRaw(Profile),
},
component: markRaw(ProfilePage),
}
],
},
{
+7 -7
View File
@@ -1,6 +1,6 @@
<template>
<div
class="-all flex py-[7px] mx-2 h-7.5 cursor-pointer items-center rounded pl-2 pr-2 text-gray-800 duration-300 ease-in-out"
class="-all flex py-[7px] mx-2 h-7.5 cursor-pointer items-center rounded pl-2 pr-2 text-ink-gray-8 duration-300 ease-in-out"
:class="{
'w-auto': isExpanded,
'w-8': !isExpanded,
@@ -12,9 +12,9 @@
>
<Tooltip :text="__(label)" v-if="!isExpanded">
<span
class="shrink-0 text-gray-700"
class="shrink-0 text-ink-gray-7"
:class="{
'text-gray-900': !isExpanded,
'text-ink-gray-9': !isExpanded,
'icon-emoji': isMobileView,
}"
>
@@ -23,9 +23,9 @@
</Tooltip>
<span
v-else
class="shrink-0 text-gray-700"
class="shrink-0 text-ink-gray-7"
:class="{
'text-gray-900': !isExpanded,
'text-ink-gray-9': !isExpanded,
'icon-emoji': isMobileView,
}"
>
@@ -67,8 +67,8 @@ const props = withDefaults(defineProps<P>(), {
isActive: false,
onClick: () => () => true,
to: "",
bgColor: "bg-white",
hvColor: "hover:bg-gray-100",
bgColor: "bg-surface-selected",
hvColor: "hover:bg-surface-gray-2",
});
const router = useRouter();
const { isMobileView } = useScreenSize();
+6 -2
View File
@@ -11,7 +11,7 @@
<div
v-for="i in numberCardsCount"
:key="`card-${i}`"
class="border rounded-md p-4 space-y-3 max-h-[140px]"
class="border rounded-md p-4 space-y-3 max-h-[140px] min-h-[114px]"
>
<div class="h-3 w-1/2 bg-surface-gray-3 rounded" />
<div class="h-7 w-2/3 bg-surface-gray-1 rounded" />
@@ -78,7 +78,11 @@
<!-- empty state -->
<div
v-if="showVariant('empty-state')"
class="bg-surface-cards/80 backdrop-blur-sm rounded-xl p-6 w-2/3 text-center pointer-events-auto space-y-0.5 relative z-10 bottom-4.5"
:style="{
backgroundImage:
'radial-gradient(ellipse at center, var(--surface-white) 10%, color-mix(in srgb, var(--surface-white) 90%, transparent) 25%, transparent 70%)',
}"
class="rounded-xl p-6 w-2/3 text-center pointer-events-auto space-y-0.5 relative z-10 bottom-4.5"
>
<div
class="relative z-10 text-ink-gray-7 font-medium text-center text-p-base leading-[1.15]"
-29
View File
@@ -1,29 +0,0 @@
<template>
<div
class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none"
>
<div
class="bg-surface-white/80 backdrop-blur-md space-y-1.5 w-64 p-4 rounded border border-white/20 shadow-2xl"
>
<div class="text-ink-gray-9 font-semibold text-center text-base">
{{ title }}
</div>
<div class="text-ink-gray-5 text-center text-p-sm">
{{ description }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
});
</script>
+15 -4
View File
@@ -2,7 +2,7 @@
<div class="rounded p-3 shadow w-full">
<FTextEditor
ref="e"
:extensions="[ComponentUtils, HandleExcelPaste]"
:extensions="[ComponentUtils, HandleExcelPaste, CleanStyles]"
v-bind="$attrs"
:editor-class="[
'prose-f max-h-64 max-w-none overflow-auto my-4 min-h-[5rem]',
@@ -33,14 +33,15 @@
class="flex flex-col space-y-1.5 overflow-auto sm:flex-row sm:justify-between"
>
<div class="flex items-center">
<TextEditorFixedMenu :buttons="fixedMenu" />
<slot name="bottom-left" />
<TextEditorFixedMenu :buttons="fixedMenu" />
</div>
<div class="flex items-center gap-2">
<Button
label="Discard"
theme="gray"
variant="subtle"
v-if="!isContentEmpty(modelValue)"
@click="
() => {
editor.commands.clearContent(true);
@@ -59,8 +60,12 @@
<script setup lang="ts">
import { UserAvatar } from "@/components";
import { useAuthStore } from "@/stores/auth";
import { ComponentUtils, HandleExcelPaste } from "@/tiptap-extensions";
import { getFontFamily } from "@/utils";
import {
CleanStyles,
ComponentUtils,
HandleExcelPaste,
} from "@/tiptap-extensions";
import { ClearFormattingUtility, getFontFamily, isContentEmpty } from "@/utils";
import { TextEditor as FTextEditor, TextEditorFixedMenu } from "frappe-ui";
import { computed, nextTick, ref } from "vue";
@@ -86,13 +91,19 @@ const authStore = useAuthStore();
const fixedMenu = [
"Paragraph",
["Heading 2", "Heading 3", "Heading 4", "Heading 5"],
"Separator",
"Bold",
"Italic",
"Separator",
"Bullet List",
"Numbered List",
"Separator",
"Image",
"Video",
"Link",
"Blockquote",
"Code",
ClearFormattingUtility,
];
defineExpose({
+6 -7
View File
@@ -1,10 +1,10 @@
<template>
<div class="flex gap-2 px-5 pb-1 leading-5 first:mt-3 items-center">
<div class="w-[106px] shrink-0 truncate text-sm text-gray-600">
<div class="flex gap-2 pb-1 leading-5 items-center">
<div class="w-[106px] shrink-0 truncate text-sm text-ink-gray-5">
<Tooltip :text="field.label">
<span>{{ field.label }}</span>
</Tooltip>
<span v-if="field.required" class="text-red-500"> * </span>
<span v-if="field.required" class="text-ink-red-3"> * </span>
</div>
<div
class="-m-0.5 min-h-[28px] flex-1 items-center overflow-hidden p-0.5 text-base"
@@ -112,8 +112,7 @@ const component = computed(() => {
});
} else if (textFields.includes(props.field.fieldtype)) {
return h(FormControl, {
type: "textarea",
rows: 1,
type: "text",
});
} else if (props.field.fieldtype === "Datetime") {
return h(DateTimePicker, {
@@ -158,7 +157,7 @@ function emitUpdate(fieldname: Field["fieldname"], value: FieldValue) {
:deep(.form-control textarea),
:deep(.form-control button) {
border-color: transparent;
background: white;
background: var(--surface-white);
}
:deep(.form-control button) {
@@ -176,7 +175,7 @@ function emitUpdate(fieldname: Field["fieldname"], value: FieldValue) {
}
:deep(.form-control button svg) {
color: white;
color: var(--ink-white);
width: 0;
}
</style>

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