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/socketio:/# socketio:/g' Procfile
sed -i 's/redis_socketio:/# redis_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 telephony
bench get-app helpdesk "${GITHUB_WORKSPACE}" bench get-app helpdesk "${GITHUB_WORKSPACE}"
bench setup requirements --dev bench setup requirements --dev
@@ -48,3 +49,7 @@ bench setup requirements --dev
bench start &>> ~/frappe-bench/bench_start.log & bench start &>> ~/frappe-bench/bench_start.log &
CI=Yes bench build --app frappe & CI=Yes bench build --app frappe &
bench --site test_site reinstall --yes 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: "[email protected]"
GIT_COMMITTER_NAME: "Frappe PR Bot"
GIT_COMMITTER_EMAIL: "[email protected]"
run: npx semantic-release
@@ -27,13 +27,13 @@ jobs:
steps: steps:
- name: Update notes - name: Update notes
run: | 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' \ | jq -r '.body' \
| sed -E '/^\* (chore|ci|test|docs|style)/d' \ | sed -E '/^\* (chore|ci|test|docs|style)/d' \
| sed -E 's/by @mergify //' | 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') 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/crm/releases/$RELEASE_ID -f body="$NEW_NOTES" gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/helpdesk/releases/$RELEASE_ID -f body="$NEW_NOTES"
env: env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+4
View File
@@ -30,6 +30,10 @@ share/python-wheels/
.installed.cfg .installed.cfg
*.egg *.egg
.playwright-mcp .playwright-mcp
.claude-flow
.claude
CLAUDE.md
helpdesk/docs/current helpdesk/docs/current
helpdesk/public/desk 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 - Use proper field types and validation in DocType definitions
- Leverage DocType methods for business logic encapsulation - 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 ## Conventions & Patterns
- **API Design:** Python modules in `helpdesk/api/` follow RESTful patterns. Extend via `helpdesk/extends/`. - **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.** **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/core": "^13.8.0",
"@vueuse/integrations": "^13.8.0", "@vueuse/integrations": "^13.8.0",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
"frappe-ui": "0.1.267", "frappe-ui": "0.1.278",
"gemoji": "^8.1.0", "gemoji": "^8.1.0",
"mime": "^3.0.0", "mime": "^3.0.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",
+8 -3
View File
@@ -9,7 +9,7 @@
import { Dialogs } from "@/components/dialogs"; import { Dialogs } from "@/components/dialogs";
import { useConfigStore } from "@/stores/config"; import { useConfigStore } from "@/stores/config";
import { useFavicon } from "@vueuse/core"; 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 { storeToRefs } from "pinia";
import { computed, defineAsyncComponent, h, onMounted } from "vue"; import { computed, defineAsyncComponent, h, onMounted } from "vue";
import Wifi from "~icons/lucide/wifi"; import Wifi from "~icons/lucide/wifi";
@@ -23,18 +23,23 @@ const { favicon } = storeToRefs(configStore);
useFavicon(favicon); useFavicon(favicon);
if (!localStorage.getItem("theme")) {
localStorage.setItem("theme", "light");
}
useTheme();
onMounted(() => { onMounted(() => {
window.addEventListener("online", () => { window.addEventListener("online", () => {
toast.create({ toast.create({
message: __("You are now online."), message: __("You are now online."),
icon: h(Wifi, { class: "text-white" }), icon: h(Wifi, { class: "text-ink-white" }),
}); });
}); });
window.addEventListener("offline", () => { window.addEventListener("offline", () => {
toast.create({ toast.create({
message: __("You are now offline."), 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); !isCustomerPortal.value && setConfig("localTimezone", window.timezone?.user);
+3 -3
View File
@@ -9,7 +9,7 @@
<template #target="{ togglePopover }"> <template #target="{ togglePopover }">
<button <button
:class="[ :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()" @click.prevent="togglePopover()"
> >
@@ -22,12 +22,12 @@
</template> </template>
<template #body> <template #body>
<div <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"> <div v-for="app in apps.data" key="name">
<a <a
:href="app.route" :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" /> <img class="size-6" :src="app.logo" />
<div class="text-sm" @click="app.onClick"> <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) }} {{ displayValue(selectedValue) }}
</span> </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 || "" }} {{ placeholder || "" }}
</span> </span>
</div> </div>
<FeatherIcon <FeatherIcon
name="chevron-down" name="chevron-down"
class="h-4 w-4 text-gray-600" class="h-4 w-4 text-ink-gray-5"
aria-hidden="true" aria-hidden="true"
/> />
</button> </button>
@@ -36,11 +36,13 @@
</template> </template>
<template #body="{ isOpen }"> <template #body="{ isOpen }">
<div v-show="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"> <div class="relative px-1.5 pt-0.5">
<ComboboxInput <ComboboxInput
ref="search" ref="search"
class="form-input w-full pr-6" class="form-input w-full pr-6 bg-transparent"
type="text" type="text"
:value="query" :value="query"
autocomplete="off" autocomplete="off"
@@ -70,7 +72,7 @@
> >
<div <div
v-if="group.group && !group.hideLabel" 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 }} {{ group.group }}
</div> </div>
@@ -84,7 +86,7 @@
<li <li
:class="[ :class="[
'flex items-center rounded px-2.5 py-1.5 text-base', 'flex items-center rounded px-2.5 py-1.5 text-base',
{ 'bg-gray-100': active }, { 'bg-surface-gray-2': active },
]" ]"
> >
<slot <slot
@@ -102,7 +104,7 @@
</div> </div>
<li <li
v-if="groups.length == 0" 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 No results found
</li> </li>
@@ -241,7 +243,7 @@ watch(showOptions, (val) => {
}); });
const textColor = computed(() => { 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(() => { const inputClasses = computed(() => {
@@ -262,12 +264,14 @@ const inputClasses = computed(() => {
let variant = props.disabled ? "disabled" : props.variant; let variant = props.disabled ? "disabled" : props.variant;
let variantClasses = { let variantClasses = {
subtle: 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: 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: [ disabled: [
"border bg-gray-50 placeholder-gray-400", "border bg-surface-menu-bar placeholder-ink-gray-3",
props.variant === "outline" ? "border-gray-300" : "border-transparent", props.variant === "outline"
? "border-outline-gray-2"
: "border-transparent",
], ],
}[variant]; }[variant];
+9 -7
View File
@@ -32,20 +32,22 @@
vs {{ currentDuration.toLowerCase() }} vs {{ currentDuration.toLowerCase() }}
<FeatherIcon name="chevron-down" class="size-4" /> <FeatherIcon name="chevron-down" class="size-4" />
</div> </div>
<template #item="{ item }"> <template #item-label="{ item }">
<div <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> <span>
{{ item.label }} {{ item.label }}
</span> </span>
<FeatherIcon
v-if="item.label == __(currentDuration)"
name="check"
class="size-4"
/>
</div> </div>
</template> </template>
<template #item-suffix="{ item }">
<FeatherIcon
v-if="item.label == __(currentDuration)"
name="check"
class="size-4"
/>
</template>
</Dropdown> </Dropdown>
</div> </div>
</div> </div>
+6 -6
View File
@@ -1,14 +1,14 @@
<template> <template>
<div class="flex-col text-base flex-1" ref="commentBoxRef"> <div class="flex-col text-base flex-1" ref="commentBoxRef">
<div class="mb-2 flex items-center justify-between"> <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 <Avatar
size="md" size="md"
:label="commenter" :label="commenter"
:image="getUser(commentedBy).user_image" :image="getUser(commentedBy).user_image"
/> />
<p> <p>
<span class="font-medium text-gray-800"> <span class="font-medium text-ink-gray-8">
{{ commenter }} {{ commenter }}
</span> </span>
<span> {{ __(" commented") }}</span> <span> {{ __(" commented") }}</span>
@@ -16,7 +16,7 @@
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<Tooltip :text="dateFormat(creation, dateTooltipFormat)"> <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) }} {{ timeAgo(creation) }}
</span> </span>
</Tooltip> </Tooltip>
@@ -28,7 +28,7 @@
> >
<Button <Button
icon="more-horizontal" icon="more-horizontal"
class="text-gray-600" class="text-ink-gray-5"
variant="ghost" variant="ghost"
/> />
</Dropdown> </Dropdown>
@@ -129,7 +129,7 @@
class="flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors" class="flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors"
:class=" :class="
reaction.current_user_reacted 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' : 'bg-surface-gray-3 text-ink-gray-6 hover:bg-surface-gray-4'
" "
v-if="reaction.count !== 0" v-if="reaction.count !== 0"
@@ -305,7 +305,7 @@ const deleteComment = createResource({
}), }),
onSuccess() { onSuccess() {
emit("update"); emit("update");
toast.success(__("Comment deleted sucessfully.")); toast.success(__("Comment deleted successfully."));
}, },
}); });
+12 -2
View File
@@ -14,7 +14,7 @@
:editable="editable" :editable="editable"
:mentions="dropdown" :mentions="dropdown"
@change="editable ? (newComment = $event) : null" @change="editable ? (newComment = $event) : null"
:extensions="[ComponentUtils, HandleExcelPaste]" :extensions="[ComponentUtils, HandleExcelPaste, CleanStyles]"
:uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)" :uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)"
> >
<template #bottom> <template #bottom>
@@ -113,7 +113,11 @@ import { AttachmentIcon } from "@/components/icons/";
import { useTyping } from "@/composables/realtime"; import { useTyping } from "@/composables/realtime";
import { useAgentStore } from "@/stores/agent"; import { useAgentStore } from "@/stores/agent";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { ComponentUtils, HandleExcelPaste } from "@/tiptap-extensions"; import {
CleanStyles,
ComponentUtils,
HandleExcelPaste,
} from "@/tiptap-extensions";
import { import {
getFontFamily, getFontFamily,
isContentEmpty, isContentEmpty,
@@ -230,6 +234,12 @@ onMounted(() => {
agentsList.value.fetch(); agentsList.value.fetch();
}); });
onBeforeUnmount(() => {
if (isContentEmpty(newComment.value)) {
localStorage.removeItem("commentBoxContent" + props.ticketId);
}
});
defineExpose({ defineExpose({
submitComment, submitComment,
editor, editor,
+98 -69
View File
@@ -8,7 +8,9 @@
ref="sendEmailRef" ref="sendEmailRef"
variant="ghost" variant="ghost"
label="Reply" label="Reply"
:class="[showEmailBox ? '!bg-gray-300 hover:!bg-gray-200' : '']" :class="[
showEmailBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
]"
@click="toggleEmailBox()" @click="toggleEmailBox()"
> >
<template #prefix> <template #prefix>
@@ -18,7 +20,9 @@
<Button <Button
variant="ghost" variant="ghost"
label="Comment" label="Comment"
:class="[showCommentBox ? '!bg-gray-300 hover:!bg-gray-200' : '']" :class="[
showCommentBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
]"
@click="toggleCommentBox()" @click="toggleCommentBox()"
> >
<template #prefix> <template #prefix>
@@ -28,71 +32,77 @@
<TypingIndicator :ticketId="ticketId" /> <TypingIndicator :ticketId="ticketId" />
</div> </div>
</div> </div>
<div <Transition name="slide">
ref="emailBoxRef" <div
v-show="showEmailBox" v-show="showEmailBox"
class="flex gap-1.5 flex-1" ref="emailBoxRef"
@keydown.ctrl.enter.capture.stop="submitEmail" @keydown.ctrl.enter.capture.stop="submitEmail"
@keydown.meta.enter.capture.stop="submitEmail" @keydown.meta.enter.capture.stop="submitEmail"
@keydown.esc.capture.stop="showEmailBox = false" @keydown.esc.capture.stop="showEmailBox = false"
> >
<EmailEditor <div class="overflow-hidden">
ref="emailEditorRef" <EmailEditor
:label=" ref="emailEditorRef"
isMobileView ? 'Send' : isMac ? 'Send (⌘ + ⏎)' : 'Send (Ctrl + ⏎)' :label="
" isMobileView ? 'Send' : isMac ? 'Send (⌘ + ⏎)' : 'Send (Ctrl + ⏎)'
v-model:content="content" "
placeholder="Hi John, we are looking into this issue." placeholder="Hi John, we are looking into this issue."
:ticketId="ticketId" :ticketId="ticketId"
:to-emails="toEmails" :to-emails="toEmails"
:cc-emails="ccEmails" :cc-emails="ccEmails"
:bcc-emails="bccEmails" :bcc-emails="bccEmails"
@submit=" @submit="
() => { () => {
showEmailBox = false; showEmailBox = false;
emit('update'); emit('update');
} }
" "
@discard=" @discard="
() => { () => {
showEmailBox = false; showEmailBox = false;
} }
" "
/> />
</div> </div>
<div </div>
ref="commentBoxRef" </Transition>
v-show="showCommentBox" <Transition name="slide">
@keydown.ctrl.enter.capture.stop="submitComment" <div
@keydown.meta.enter.capture.stop="submitComment" v-show="showCommentBox"
@keydown.esc.capture.stop="showCommentBox = false" ref="commentBoxRef"
> @keydown.ctrl.enter.capture.stop="submitComment"
<CommentTextEditor @keydown.meta.enter.capture.stop="submitComment"
ref="commentTextEditorRef" @keydown.esc.capture.stop="showCommentBox = false"
:label=" >
isMobileView <div class="overflow-hidden">
? 'Comment' <CommentTextEditor
: isMac ref="commentTextEditorRef"
? 'Comment (⌘ + ⏎)' :label="
: 'Comment (Ctrl + ⏎)' isMobileView
" ? 'Comment'
:ticketId="ticketId" : isMac
:editable="showCommentBox" ? 'Comment (⌘ + ⏎)'
:doctype="doctype" : 'Comment (Ctrl + ⏎)'
placeholder="@John could you please look into this?" "
@submit=" :ticketId="ticketId"
() => { :editable="showCommentBox"
showCommentBox = false; :doctype="doctype"
emit('update'); placeholder="@John could you please look into this?"
} @submit="
" () => {
@discard=" showCommentBox = false;
() => { emit('update');
showCommentBox = false; }
} "
" @discard="
/> () => {
</div> showCommentBox = false;
}
"
/>
</div>
</div>
</Transition>
</div> </div>
</template> </template>
@@ -188,7 +198,7 @@ watch(
() => showEmailBox.value, () => showEmailBox.value,
(value) => { (value) => {
if (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; 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> </style>
+5 -1
View File
@@ -2,7 +2,11 @@
<Dialog v-model="showDialog" :options="{ title }" @close="closeDialog"> <Dialog v-model="showDialog" :options="{ title }" @close="closeDialog">
<template #body-content> <template #body-content>
<div class="space-y-4"> <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> </div>
</template> </template>
<template #actions> <template #actions>
+52 -44
View File
@@ -2,7 +2,7 @@
<div <div
:id="`communication-${name}`" :id="`communication-${name}`"
v-bind="$attrs" 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 <div
class="flex items-center justify-between gap-2" class="flex items-center justify-between gap-2"
@@ -35,7 +35,7 @@
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<div class="gap-0.5 flex items-center"> <div class="gap-0.5 flex items-center">
<Badge <Badge
v-if="status.label && !ticket.doc.via_customer_portal" v-if="status.label && !ticket?.doc?.via_customer_portal"
:label="__(status.label)" :label="__(status.label)"
variant="subtle" variant="subtle"
:theme="status.color" :theme="status.color"
@@ -50,12 +50,16 @@
</p> </p>
</Tooltip> </Tooltip>
</div> </div>
<div class="flex items-center"> <div class="flex items-center gap-1">
<Button variant="ghost" @click="reply"> <Button :tooltip="__('Reply')" variant="ghost" @click="reply">
<ReplyIcon class="h-4 w-4 text-ink-gray-5" /> <template #icon>
<ReplyIcon class="text-ink-gray-7" />
</template>
</Button> </Button>
<Button variant="ghost" @click="replyAll"> <Button :tooltip="__('Reply All')" variant="ghost" @click="replyAll">
<ReplyAllIcon class="h-4 w-6 text-ink-gray-5" /> <template #icon>
<ReplyAllIcon class="text-ink-gray-7" />
</template>
</Button> </Button>
<Dropdown <Dropdown
v-if="showSplitOption" v-if="showSplitOption"
@@ -70,7 +74,7 @@
> >
<Button <Button
icon="more-horizontal" icon="more-horizontal"
class="text-ink-gray-5 ml-0.5" class="!text-ink-gray-7"
variant="ghost" variant="ghost"
/> />
</Dropdown> </Dropdown>
@@ -80,15 +84,16 @@
<!-- <div class="text-sm leading-5 text-ink-gray-5"> <!-- <div class="text-sm leading-5 text-ink-gray-5">
{{ subject }} {{ subject }}
</div> --> </div> -->
<div class="text-sm leading-5 text-ink-gray-5"> <div class="text-p-sm text-ink-gray-5">
<span v-if="to" class="mr-1">To:</span> <template
<span v-if="to"> {{ to }} </span> v-for="(val, label) in { To: to, cc: cc, bcc: bcc }"
<span v-if="cc">, </span> :key="label"
<span v-if="cc"> Cc: </span> >
<span v-if="cc">{{ cc }}</span> <span v-if="val" class="mr-1.5">
<span v-if="bcc">, </span> <span class="mr-1 text-ink-gray-7">{{ label }}:</span>
<span v-if="bcc"> Bcc: </span> <span> {{ normalizeAndFilter(val).join(", ") }}</span>
<span v-if="bcc">{{ bcc }}</span> </span>
</template>
</div> </div>
<div class="border-0 border-t my-3 border-outline-gray-modals !-mx-3" /> <div class="border-0 border-t my-3 border-outline-gray-modals !-mx-3" />
<EmailContent :content="content" /> <EmailContent :content="content" />
@@ -169,6 +174,32 @@ const status = computed(() => {
return { label: _status, color: indicator_color }; 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 reply = () => {
const user = auth.user.value; const user = auth.user.value;
emit("reply", { emit("reply", {
@@ -179,33 +210,10 @@ const reply = () => {
const replyAll = () => { const replyAll = () => {
const user = auth.user.value; const user = auth.user.value;
const exclude = [user, sender.name];
const normalizeAndFilter = (field) => { const filteredTo = normalizeAndFilter(to, exclude);
let arr = []; const filteredCc = normalizeAndFilter(cc, exclude);
let current = ""; const filteredBcc = normalizeAndFilter(bcc, exclude);
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);
let _to, _cc, _bcc; let _to, _cc, _bcc;
+29 -4
View File
@@ -7,7 +7,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getFontFamily } from "@/utils"; import { dataTheme, getFontFamily, stripEmailColors } from "@/utils";
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
const props = defineProps({ const props = defineProps({
@@ -18,7 +18,7 @@ const props = defineProps({
}); });
const iframeRef = ref<HTMLIFrameElement | null>(null); 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 // Get CSS path - in dev Vite serves it directly, in prod we need the built path
const cssHref = computed(() => { const cssHref = computed(() => {
@@ -178,6 +178,12 @@ const htmlContent = computed(
.email-content :where(img):not(:where([class~='not-prose'], [class~='not-prose'] *)) { .email-content :where(img):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
margin: 0; 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> </style>
</head> </head>
@@ -197,8 +203,7 @@ watch(iframeRef, (iframe) => {
const parent = emailContent.closest("html"); const parent = emailContent.closest("html");
if (!parent) return; if (!parent) return;
let theme = document.documentElement.getAttribute("data-theme"); parent.setAttribute("data-theme", dataTheme.value);
parent.setAttribute("data-theme", theme);
const font = getFontFamily(_content.value); const font = getFontFamily(_content.value);
if (font) emailContent.classList.add(font); 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"); const replyCollapsers = emailContent.querySelectorAll(".replyCollapser");
if (replyCollapsers.length) { if (replyCollapsers.length) {
replyCollapsers.forEach((replyCollapser) => { 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> </script>
+241 -131
View File
@@ -11,13 +11,27 @@
:placeholder="placeholder" :placeholder="placeholder"
:editable="editable" :editable="editable"
@change="editable ? (newEmail = $event) : null" @change="editable ? (newEmail = $event) : null"
:extensions="[ComponentUtils, HandleExcelPaste]" :extensions="[ComponentUtils, HandleExcelPaste, CleanStyles]"
:uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)" :uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)"
@keydown.capture="handleKeydown" @keydown.capture="handleKeydown"
> >
<template #top> <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"> <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 <MultiSelectInput
v-model="toEmailsClone" v-model="toEmailsClone"
class="flex-1" class="flex-1"
@@ -27,7 +41,7 @@
/> />
<div class="flex gap-1.5"> <div class="flex gap-1.5">
<Button <Button
:label="'CC'" :label="__('Cc')"
variant="ghost" variant="ghost"
:class="[ :class="[
cc || showCC cc || showCC
@@ -37,7 +51,7 @@
@click="toggleCC()" @click="toggleCC()"
/> />
<Button <Button
:label="'BCC'" :label="__('Bcc')"
variant="ghost" variant="ghost"
:class="[ :class="[
bcc || showBCC bcc || showBCC
@@ -53,7 +67,7 @@
class="mx-5 flex items-center gap-2 py-2.5" class="mx-5 flex items-center gap-2 py-2.5"
:class="cc || showCC ? 'border-b' : ''" :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 <MultiSelectInput
ref="ccInput" ref="ccInput"
v-model="ccEmailsClone" v-model="ccEmailsClone"
@@ -68,7 +82,7 @@
class="mx-5 flex items-center gap-2 py-2.5" class="mx-5 flex items-center gap-2 py-2.5"
:class="bcc || showBCC ? 'border-b' : ''" :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 <MultiSelectInput
ref="bccInput" ref="bccInput"
v-model="bccEmailsClone" v-model="bccEmailsClone"
@@ -81,15 +95,28 @@
</template> </template>
<template #editor> <template #editor>
<div class="overflow-y-auto min-h-[7rem] max-h-[30vh]"> <div class="overflow-y-auto min-h-[7rem] max-h-[30vh] flex flex-col">
<EditorContent :editor="editor" /> <div class="flex-1">
<EditorContent :editor="editor" />
</div>
<div <div
v-if="quotedContent" v-if="quotedContent"
ref="quotedContentRef" class="replied-content mx-6 md:mx-5 mb-2 mt-auto"
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" <label class="collapse" for="quoted-toggle">...</label>
@input="onQuotedInput" <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> </div>
</template> </template>
<template #bottom> <template #bottom>
@@ -170,7 +197,6 @@
</template> </template>
</TextEditor> </TextEditor>
<SavedRepliesSelectorModal <SavedRepliesSelectorModal
v-if="showSavedRepliesSelectorModal"
v-model="showSavedRepliesSelectorModal" v-model="showSavedRepliesSelectorModal"
:doctype="doctype" :doctype="doctype"
@apply="applySavedReplies" @apply="applySavedReplies"
@@ -187,9 +213,14 @@ import {
import { AttachmentIcon } from "@/components/icons"; import { AttachmentIcon } from "@/components/icons";
import { useTyping } from "@/composables/realtime"; import { useTyping } from "@/composables/realtime";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { ComponentUtils, HandleExcelPaste } from "@/tiptap-extensions"; import {
CleanStyles,
ComponentUtils,
HandleExcelPaste,
} from "@/tiptap-extensions";
import { import {
getFontFamily, getFontFamily,
htmlToText,
isContentEmpty, isContentEmpty,
removeAttachmentFromServer, removeAttachmentFromServer,
textEditorMenuButtons, textEditorMenuButtons,
@@ -216,10 +247,7 @@ import {
} from "vue"; } from "vue";
import SavedReplyIcon from "./icons/SavedReplyIcon.vue"; import SavedReplyIcon from "./icons/SavedReplyIcon.vue";
const editorRef = ref(null); // ─── Props & Emits ────────────────────────────────────────────
const showSavedRepliesSelectorModal = ref(false);
const quotedContentRef = ref<HTMLElement | null>(null);
const props = defineProps({ const props = defineProps({
ticketId: { ticketId: {
type: String, type: String,
@@ -255,49 +283,95 @@ const props = defineProps({
}, },
}); });
const label = computed(() => {
return sendMail.loading ? "Sending..." : props.label;
});
const emit = defineEmits(["submit", "discard"]); 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, "emailBoxContent" + props.ticketId,
null 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>( const quotedContent = useStorage<null | string>(
"quotedEmailBoxContent" + props.ticketId, "quotedEmailBoxContent" + props.ticketId,
null null
); );
const quotedContentRef = ref<HTMLElement | null>(null);
const isQuoteExpanded = ref(false);
const { updateOnboardingStep } = useOnboarding("helpdesk"); function onQuotedInput() {
const { isManager } = useAuthStore(); const el = quotedContentRef.value;
if (!el) return;
quotedContent.value = el.innerHTML || null;
}
// Initialize typing composable watch(quotedContent, (newVal, oldVal) => {
const { onUserType, cleanup } = useTyping(props.ticketId); if (!oldVal && newVal) {
nextTick(() => {
const attachments = ref([]); if (quotedContentRef.value) {
const isUploading = ref(false); quotedContentRef.value.innerHTML = newVal;
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();
} }
}); });
onBeforeUnmount(() => { watch(
cleanup(); () => 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]); const toEmailsClone = ref([...props.toEmails]);
@@ -310,74 +384,8 @@ const bcc = computed(() => (bccEmailsClone.value?.length ? true : false));
const ccInput = ref(null); const ccInput = ref(null);
const bccInput = 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() { function toggleCC() {
showCC.value = !showCC.value; showCC.value = !showCC.value;
showCC.value && showCC.value &&
nextTick(() => { nextTick(() => {
ccInput.value.setFocus(); 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) { async function removeAttachment(attachment) {
attachments.value = attachments.value.filter((a) => a !== attachment); attachments.value = attachments.value.filter((a) => a !== attachment);
await removeAttachmentFromServer(attachment.name); 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( function addToReply(
body: string, body: string,
toEmails: string[], toEmails: string[],
@@ -410,46 +526,40 @@ function addToReply(
if (body !== quotedContent.value) { if (body !== quotedContent.value) {
//trigger change for watch when replied to body data is different from current quoted content //trigger change for watch when replied to body data is different from current quoted content
quotedContent.value = null; quotedContent.value = null;
isQuoteExpanded.value = false;
nextTick(() => { nextTick(() => {
quotedContent.value = body; quotedContent.value = body;
}); });
} }
editorRef.value.editor.chain().clearContent().focus("start").run();
nextTick(() => { nextTick(() => {
newEmail.value = editorRef.value.editor.getHTML(); newEmail.value = getInitialContent();
}); });
focusEditorAtStart();
} }
function resetState() { function resetState() {
newEmail.value = null; newEmail.value = emailSignature.value ? emailSignature.value : null;
attachments.value = []; attachments.value = [];
quotedContent.value = null; quotedContent.value = null;
isQuoteExpanded.value = false;
focusEditorAtStart();
} }
function handleDiscard() { function handleDiscard() {
attachments.value = []; attachments.value = [];
newEmail.value = null; newEmail.value = getInitialContent();
quotedContent.value = null; quotedContent.value = null;
ccEmailsClone.value = []; ccEmailsClone.value = [];
bccEmailsClone.value = []; bccEmailsClone.value = [];
showCC.value = false; showCC.value = false;
showBCC.value = false; showBCC.value = false;
isQuoteExpanded.value = false;
focusEditorAtStart();
emit("discard"); 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) { function handleSelectAll(e: KeyboardEvent) {
const active = document.activeElement; const active = document.activeElement;
const editorContext = editorRef.value?.editor; const editorContext = editorRef.value?.editor;
@@ -484,7 +594,6 @@ function handleDelete(e: KeyboardEvent) {
if (!sel || sel.isCollapsed || !quotedEl || !editorDom) return; if (!sel || sel.isCollapsed || !quotedEl || !editorDom) return;
const isSelectingEntireEditor = sel.containsNode(editorDom, true); const isSelectingEntireEditor = sel.containsNode(editorDom, true);
const isSelectingEntireQuote = sel.containsNode(quotedEl, true); const isSelectingEntireQuote = sel.containsNode(quotedEl, true);
if (isSelectingEntireEditor && isSelectingEntireQuote) { if (isSelectingEntireEditor && isSelectingEntireQuote) {
@@ -502,6 +611,7 @@ function handleKeydown(e: KeyboardEvent) {
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
if ((e.metaKey || e.ctrlKey) && key === "a") { if ((e.metaKey || e.ctrlKey) && key === "a") {
isQuoteExpanded.value = true;
handleSelectAll(e); handleSelectAll(e);
return; return;
} }
@@ -512,8 +622,8 @@ function handleKeydown(e: KeyboardEvent) {
} }
} }
const editor = computed(() => { onBeforeUnmount(() => {
return editorRef.value.editor; cleanup();
}); });
defineExpose({ defineExpose({
+54 -5
View File
@@ -1,16 +1,61 @@
<template> <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 <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 --> <!-- Icon -->
<component v-if="icon" :is="icon" class="h-10 w-10" /> <component v-if="icon" :is="icon" class="h-10 w-10" />
<!-- title --> <!-- title -->
<div class="flex flex-col items-center justify-center"> <div class="flex flex-col items-center justify-center gap-0.5">
<span class="text-lg font-medium text-ink-gray-8">{{ __(title) }}</span> <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 <span
v-if="descriptionText" 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) }} {{ __(descriptionText) }}
</span> </span>
@@ -25,11 +70,15 @@ interface Props {
title: string; title: string;
icon?: VNode | string; icon?: VNode | string;
description?: string; description?: string;
variant?: "default" | "overlay" | "badge";
text?: "sm" | "md" | "lg";
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
title: "No Data Found", title: "No Data Found",
icon: "", icon: "",
variant: "default",
text: "lg",
}); });
const descriptionText = computed(() => const descriptionText = computed(() =>
+9 -8
View File
@@ -22,14 +22,14 @@
</template> </template>
</Button> </Button>
</div> </div>
<div class="text-gray-600 w-4/6 text-p-base" v-else> <div class="text-ink-gray-5 w-4/6 text-p-base" v-else>
<span class="font-medium text-gray-800"> <span class="font-medium text-ink-gray-8">
{{ user }} {{ user }}
</span> </span>
<span> {{ content }}</span> <span> {{ content }}</span>
</div> </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)"> <Tooltip :text="dateFormat(creation, dateTooltipFormat)">
<span>{{ timeAgo(creation) }}</span> <span>{{ timeAgo(creation) }}</span>
</Tooltip> </Tooltip>
@@ -41,8 +41,8 @@
:key="relatedActivity.creation" :key="relatedActivity.creation"
class="flex justify-between text-base" class="flex justify-between text-base"
> >
<div class="text-gray-600 w-4/6"> <div class="text-ink-gray-5 w-4/6">
<span class="font-medium text-gray-800"> <span class="font-medium text-ink-gray-8">
{{ relatedActivity.user }} {{ relatedActivity.user }}
</span> </span>
<span> {{ relatedActivity.content }}</span> <span> {{ relatedActivity.content }}</span>
@@ -50,7 +50,7 @@
<Tooltip <Tooltip
:text="dateFormat(relatedActivity.creation, dateTooltipFormat)" :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) }} {{ timeAgo(relatedActivity.creation) }}
</div> </div>
</Tooltip> </Tooltip>
@@ -62,7 +62,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SelectIcon } from "@/components/icons"; import { SelectIcon } from "@/components/icons";
import { dateFormat, dateTooltipFormat, timeAgo } from "@/utils"; import { dateFormat, dateTooltipFormat, timeAgo } from "@/utils";
import { ref } from "vue"; import { computed, ref } from "vue";
const props = defineProps({ const props = defineProps({
activity: { activity: {
type: Object, 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); let show_others = ref(false);
</script> </script>
+1
View File
@@ -78,6 +78,7 @@ const chartData = computed(() => {
const _data: AverageResponseData = isDataFetched ? resource.data : props.data; const _data: AverageResponseData = isDataFetched ? resource.data : props.data;
const dates = _data?.data?.map((item) => item.date) || []; const dates = _data?.data?.map((item) => item.date) || [];
const seriesData = const seriesData =
_data?.data?.map((item) => _data?.data?.map((item) =>
props.type === "Time" ? item.avg_time : item.count props.type === "Time" ? item.avg_time : item.count
+84 -18
View File
@@ -35,9 +35,16 @@
</div> </div>
</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 --> <!-- List View -->
<ListView <ListView
v-if="list.data?.data.length > 0" v-else-if="list.data?.data.length > 0"
class="flex-1" class="flex-1"
:columns="columns" :columns="columns"
:rows="rows" :rows="rows"
@@ -45,7 +52,7 @@
:options="{ :options="{
selectable: options.selectable, selectable: options.selectable,
showTooltip: false, showTooltip: false,
resizeColumn: false, resizeColumn: true,
getRowRoute: (row) => ({ getRowRoute: (row) => ({
name: options.rowRoute?.name, name: options.rowRoute?.name,
params: { [options.rowRoute?.prop]: row.name }, params: { [options.rowRoute?.prop]: row.name },
@@ -59,7 +66,7 @@
v-for="column in columns" v-for="column in columns"
:key="column.key" :key="column.key"
:item="column" :item="column"
@columnWidthUpdated="(width) => console.log(width)" @columnWidthUpdated="handleColumnResize"
/> />
</ListHeader> </ListHeader>
<ListRows <ListRows
@@ -106,16 +113,9 @@
" "
/> />
</div> </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 --> <!-- Empty State -->
<EmptyState <EmptyState
v-else v-else-if="!list.loading"
:title="emptyState.title" :title="emptyState.title"
:icon="emptyState.icon" :icon="emptyState.icon"
:description="emptyState.description" :description="emptyState.description"
@@ -146,10 +146,10 @@ import { View, ViewType } from "@/types";
import { getIcon } from "@/utils"; import { getIcon } from "@/utils";
import { useStorage } from "@vueuse/core"; import { useStorage } from "@vueuse/core";
import { import {
call,
createResource, createResource,
Dropdown, Dropdown,
FeatherIcon, FeatherIcon,
frappeRequest,
ListFooter, ListFooter,
ListHeader, ListHeader,
ListHeaderItem, ListHeaderItem,
@@ -207,7 +207,7 @@ const emit = defineEmits<E>();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { isManager } = useAuthStore(); const { isManager } = useAuthStore();
const { $dialog } = globalStore(); const { $dialog, $socket } = globalStore();
const { getStatus } = useTicketStatusStore(); const { getStatus } = useTicketStatusStore();
const listSelections = ref(new Set()); const listSelections = ref(new Set());
@@ -258,11 +258,69 @@ const defaultOptions = reactive({
function handleBulkDelete(hide: Function, selections: Set<string>) { function handleBulkDelete(hide: Function, selections: Set<string>) {
capture("bulk_delete" + props.options.doctype); capture("bulk_delete" + props.options.doctype);
call("frappe.desk.reportview.delete_items", { const requested = Array.from(selections);
items: JSON.stringify(Array.from(selections)), const requestedCount = requested.length;
doctype: props.options.doctype,
}).then(() => { const failureMessages: string[] = [];
toast.success(__("Item(s) deleted successfully.")); 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(); hide();
reset(); reset();
}); });
@@ -748,6 +806,14 @@ function handleScrollPosition() {
}, 200); }, 200);
} }
function handleColumnResize() {
isViewUpdated.value = true;
defaultParams.columns = columns.value;
if (!defaultParams.is_default) return;
handleViewUpdate();
isViewUpdated.value = false;
}
onMounted(async () => { onMounted(async () => {
handleScrollPosition(); handleScrollPosition();
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <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 <Pill
v-for="item in items" v-for="item in items"
:key="item.value" :key="item.value"
+7 -9
View File
@@ -8,10 +8,6 @@
:label="value" :label="value"
theme="gray" theme="gray"
variant="subtle" 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" @keydown.delete.capture.stop="removeLastValue"
> >
<template #suffix> <template #suffix>
@@ -28,7 +24,7 @@
<template #target="{ togglePopover }"> <template #target="{ togglePopover }">
<ComboboxInput <ComboboxInput
ref="search" 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" type="text"
:value="query" :value="query"
autocomplete="off" autocomplete="off"
@@ -44,7 +40,9 @@
</template> </template>
<template #body="{ isOpen }"> <template #body="{ isOpen }">
<div v-show="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 <ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5" class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static static
@@ -58,7 +56,7 @@
<li <li
:class="[ :class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base', 'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-gray-100': active }, { 'bg-surface-gray-2': active },
]" ]"
> >
<UserAvatar <UserAvatar
@@ -66,11 +64,11 @@
:name="getUsernameLabel(option.value)" :name="getUsernameLabel(option.value)"
size="lg" 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"> <div class="text-base font-medium">
{{ getUsernameLabel(option.label) }} {{ getUsernameLabel(option.label) }}
</div> </div>
<div class="text-sm text-gray-600"> <div class="text-sm text-ink-gray-5">
{{ option.value }} {{ option.value }}
</div> </div>
</div> </div>
+1 -1
View File
@@ -21,7 +21,7 @@
:text="avatar.name" :text="avatar.name"
> >
<Avatar <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" shape="circle"
:image="avatar.image" :image="avatar.image"
:label="avatar.label" :label="avatar.label"
+1 -1
View File
@@ -2,7 +2,7 @@
<header class="border-b px-5 py-2.5"> <header class="border-b px-5 py-2.5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<slot name="title"> <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 }} {{ title }}
</div> </div>
</slot> </slot>
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="relative" :style="{ width: `${sidebarWidth}px` }"> <div class="relative" :style="{ width: `${sidebarWidth}px` }">
<slot v-bind="{ sidebarResizing, sidebarWidth }" /> <slot v-bind="{ sidebarResizing, sidebarWidth }" />
<div <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="{ :class="{
'opacity-100': sidebarResizing, 'opacity-100': sidebarResizing,
'left-0': side == 'right', 'left-0': side == 'right',
@@ -5,6 +5,7 @@
size: '4xl', size: '4xl',
}" }"
@vue:unmounted="resetFilter" @vue:unmounted="resetFilter"
@after-leave="onAfterLeave"
> >
<template #body> <template #body>
<div class="max-h-[575px]" :style="{ height: 'calc(100vh - 8rem)' }"> <div class="max-h-[575px]" :style="{ height: 'calc(100vh - 8rem)' }">
@@ -26,7 +27,7 @@
@input="search = $event" @input="search = $event"
:placeholder="__('Search')" :placeholder="__('Search')"
type="text" 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" icon-left="search"
debounce="300" debounce="300"
inputClass="p-4 pr-12" inputClass="p-4 pr-12"
@@ -70,7 +71,7 @@
<div <div
v-for="template in savedReplyListResource?.data" v-for="template in savedReplyListResource?.data"
:key="template.name" :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)" @click="onTemplateSelect(template)"
> >
<div class="text-base font-semibold truncate border-b pb-2"> <div class="text-base font-semibold truncate border-b pb-2">
@@ -80,7 +81,7 @@
v-if="template.message" v-if="template.message"
:content="template.message" :content="template.message"
:editable="false" :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" class="flex-1 overflow-hidden pointer-events-none"
/> />
<div <div
@@ -88,7 +89,7 @@
selectedTemplate.name === template.name && selectedTemplate.name === template.name &&
selectedTemplate.isLoading 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" /> <LoadingIndicator class="size-4" />
</div> </div>
@@ -102,7 +103,7 @@
class="mt-2" class="mt-2"
> >
<div class="flex h-56 flex-col items-center justify-center"> <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") }} {{ __("No saved replies found") }}
</div> </div>
</div> </div>
@@ -130,8 +131,7 @@ import {
TextEditor, TextEditor,
} from "frappe-ui"; } from "frappe-ui";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { computed, nextTick, onUnmounted, ref, watch } from "vue"; import { computed, nextTick, ref, watch } from "vue";
import { showEmailBox } from "../pages/ticket/modalStates";
import { import {
setActiveSettingsTab, setActiveSettingsTab,
showSettingsModal, showSettingsModal,
@@ -199,6 +199,14 @@ const selectedTemplate = ref({
name: "", name: "",
isLoading: false, isLoading: false,
}); });
const pendingTemplate = ref<string | null>(null);
function onAfterLeave() {
if (pendingTemplate.value !== null) {
emit("apply", pendingTemplate.value);
pendingTemplate.value = null;
}
}
const scope = computed(() => { const scope = computed(() => {
return filters.value.find((f) => f.value === activeFilter.value)?.value; return filters.value.find((f) => f.value === activeFilter.value)?.value;
@@ -217,10 +225,6 @@ const savedReplyListResource = createListResource({
pageLength: 999, pageLength: 999,
}); });
onUnmounted(() => {
showEmailBox.value = true;
});
const onTemplateSelect = (template: SavedReply) => { const onTemplateSelect = (template: SavedReply) => {
if (selectedTemplate.value.isLoading) return; if (selectedTemplate.value.isLoading) return;
selectedTemplate.value = { selectedTemplate.value = {
@@ -238,7 +242,10 @@ const onTemplateSelect = (template: SavedReply) => {
name: "", name: "",
isLoading: false, 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"); capture("saved_reply_applied");
}, },
}); });
+5 -5
View File
@@ -6,7 +6,7 @@
<div class="mb-2 font-medium pl-2" v-if="!hideViewAll"> <div class="mb-2 font-medium pl-2" v-if="!hideViewAll">
These articles may already cover what you are looking for These articles may already cover what you are looking for
<RouterLink <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="{ :to="{
name: 'CustomerKnowledgeBase', name: 'CustomerKnowledgeBase',
}" }"
@@ -25,7 +25,7 @@
class="rounded-md border-2 p-2 border-hidden hover:bg-surface-gray-2" class="rounded-md border-2 p-2 border-hidden hover:bg-surface-gray-2"
> >
<RouterLink <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="{ :to="{
name: 'ArticlePublic', name: 'ArticlePublic',
params: { params: {
@@ -39,7 +39,7 @@
<dt class="font-base">{{ a.subject }} - {{ a.headings }}</dt> <dt class="font-base">{{ a.subject }} - {{ a.headings }}</dt>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<dd <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" v-html="a.description"
></dd> ></dd>
</RouterLink> </RouterLink>
@@ -55,7 +55,7 @@
<LucideSearch class="size-8 text-ink-gray-3" /> <LucideSearch class="size-8 text-ink-gray-3" />
<div class="flex items-center flex-col justify-center"> <div class="flex items-center flex-col justify-center">
<p class="font-base">No answers found</p> <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 >Rephrase the question and try again with some keywords</span
> >
</div> </div>
@@ -67,7 +67,7 @@
<LucideSearch class="size-8 text-ink-gray-3" /> <LucideSearch class="size-8 text-ink-gray-3" />
<div class="flex items-center flex-col justify-center"> <div class="flex items-center flex-col justify-center">
<p class="font-base">Searching...</p> <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 >Please wait while we search for the answers</span
> >
</div> </div>
+1 -1
View File
@@ -23,7 +23,7 @@
v-if="option.image" v-if="option.image"
:image="option.image" :image="option.image"
:label="option.label" :label="option.label"
class="border-2 border-white flex-shrink-0" class="border-2 border-[var(--surface-white)] flex-shrink-0"
size="sm" size="sm"
/> />
</div> </div>
+2 -2
View File
@@ -33,8 +33,8 @@
<transition <transition
enter-active-class="duration-300 ease-in" enter-active-class="duration-300 ease-in"
leave-active-class="duration-300 ease-[cubic-bezier(0, 1, 0.5, 1)]" leave-active-class="duration-300 ease-[cubic-bezier(0, 1, 0.5, 1)]"
enter-to-class="max-h-[200px] overflow-hidden" enter-to-class="max-h-[2000px] overflow-hidden"
leave-from-class="max-h-[200px] overflow-hidden" leave-from-class="max-h-[2000px] overflow-hidden"
enter-from-class="max-h-0 opacity-0" enter-from-class="max-h-0 opacity-0"
leave-to-class="max-h-0 opacity-0" leave-to-class="max-h-0 opacity-0"
> >
+1 -1
View File
@@ -16,7 +16,7 @@
</template> </template>
<template #body="{ togglePopover }"> <template #body="{ togglePopover }">
<div <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" :class="bodyClass"
> >
<div class="max-h-52 overflow-y-auto"> <div class="max-h-52 overflow-y-auto">
+14 -25
View File
@@ -22,7 +22,7 @@
@input="search = $event" @input="search = $event"
:placeholder="__('Search')" :placeholder="__('Search')"
type="text" 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" icon-left="search"
debounce="300" debounce="300"
inputClass="p-4 pr-12" inputClass="p-4 pr-12"
@@ -49,9 +49,9 @@
</template> </template>
</Button> </Button>
</template> </template>
<template #item="{ item }"> <template #item-label="{ item }">
<button <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" @click="item.onClick"
> >
<div class="flex items-center justify-between flex-1"> <div class="flex items-center justify-between flex-1">
@@ -84,35 +84,24 @@
/> />
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div <EmptyState
v-if="!agents.loading && !agents.data?.length" v-if="!agents.loading && !agents.data?.length"
class="flex flex-col items-center justify-center gap-4 h-full" variant="badge"
> :icon="AgentIcon"
<div title="No agent found"
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex items-center justify-center" :description="
> activeFilter.length
<AgentIcon class="size-6 text-ink-gray-6" /> ? 'Change your search terms or filters'
</div> : 'Add one to get started.'
<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>
<!-- Agent List --> <!-- Agent List -->
<div <div
class="w-full" class="w-full"
v-if="!agents.loading && Boolean(agents.data?.length)" v-if="!agents.loading && Boolean(agents.data?.length)"
> >
<div <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 class="col-span-6 text-p-sm">{{ __("Agent name") }}</div>
</div> </div>
@@ -42,7 +42,7 @@
</template> </template>
<template #body="{ togglePopover }"> <template #body="{ togglePopover }">
<div <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 <div
v-for="option in ticketRoutingOptions" v-for="option in ticketRoutingOptions"
@@ -97,7 +97,7 @@
:placement="'top'" :placement="'top'"
> >
<div <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") }} {{ __("Last") }}
</div> </div>
@@ -10,11 +10,13 @@
/> />
</template> </template>
<template #body="{ togglePopover }"> <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"> <div class="relative px-1.5 pt-0.5">
<ComboboxInput <ComboboxInput
ref="search" ref="search"
class="form-input w-full" class="form-input w-full bg-transparent"
type="text" type="text"
@change=" @change="
(e) => { (e) => {
@@ -49,7 +51,7 @@
> >
<li <li
class="flex items-center rounded p-1.5 w-full text-base" 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"> <div class="flex gap-2 items-center w-full select-none">
<Avatar <Avatar
@@ -71,7 +73,7 @@
</ComboboxOption> </ComboboxOption>
<li <li
v-if="users.length == 0" 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") }} {{ __("No results found") }}
</li> </li>
@@ -1,6 +1,6 @@
<template> <template>
<div <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 <div
@click="assignmentRulesActiveScreen = { screen: 'view', data: data }" @click="assignmentRulesActiveScreen = { screen: 'view', data: data }"
@@ -12,13 +12,7 @@
@click="goBack()" @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" 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 <UnsavedBadge :show="isDirty" />
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
</div> </div>
</template> </template>
<template #header-actions> <template #header-actions>
@@ -47,7 +41,7 @@
<template #content> <template #content>
<div <div
v-if="getAssignmentRuleData.loading" 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" /> <LoadingIndicator class="w-4" />
</div> </div>
@@ -90,12 +84,12 @@
</template> </template>
<template #body="{ togglePopover }"> <template #body="{ togglePopover }">
<div <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 <div
v-for="option in priorityOptions" v-for="option in priorityOptions"
:key="option.value" :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=" @click="
assignmentRuleData.priority = option.value; assignmentRuleData.priority = option.value;
togglePopover(); togglePopover();
@@ -164,7 +158,7 @@
</template> </template>
<template #body-main> <template #body-main>
<div <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> <code>{{ assignmentRuleData.assignCondition }}</code>
</div> </div>
@@ -175,7 +169,7 @@
</div> </div>
<div class="mt-5"> <div class="mt-5">
<div <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=" v-if="
!useNewUIForAssignCondition && !useNewUIForAssignCondition &&
assignmentRuleData.assignCondition assignmentRuleData.assignCondition
@@ -245,7 +239,7 @@
</template> </template>
<template #body-main> <template #body-main>
<div <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> <code>{{ assignmentRuleData.unassignCondition }}</code>
</div> </div>
@@ -256,7 +250,7 @@
</div> </div>
<div class="mt-5"> <div class="mt-5">
<div <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=" v-if="
!useNewUIForUnassignCondition && !useNewUIForUnassignCondition &&
assignmentRuleData.unassignCondition assignmentRuleData.unassignCondition
@@ -356,6 +350,7 @@ import { convertToConditions } from "@/utils";
import { disableSettingModalOutsideClick } from "../settingsModal"; import { disableSettingModalOutsideClick } from "../settingsModal";
import { __ } from "@/translation"; import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue"; import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
const isDirty = ref(false); const isDirty = ref(false);
const initialData = ref(null); const initialData = ref(null);
@@ -36,7 +36,7 @@
@input="assignmentRuleSearchQuery = $event" @input="assignmentRuleSearchQuery = $event"
:placeholder="__('Search')" :placeholder="__('Search')"
type="text" 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" icon-left="search"
debounce="300" debounce="300"
inputClass="p-4 pr-12" inputClass="p-4 pr-12"
@@ -25,7 +25,7 @@
</div> </div>
</div> </div>
<div v-else> <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-7 ml-2">{{ __("Assignment rule") }}</div>
<div class="col-span-3">{{ __("Priority") }}</div> <div class="col-span-3">{{ __("Priority") }}</div>
<div class="col-span-2">{{ __("Enabled") }}</div> <div class="col-span-2">{{ __("Enabled") }}</div>
@@ -7,7 +7,7 @@
/> />
<div <div
v-if="props.conditions.length == 0" 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=" @click="
props.conditions.push(['', '', '']); props.conditions.push(['', '', '']);
validateAssignmentRule(props.name); validateAssignmentRule(props.name);
@@ -1,5 +1,5 @@
<template> <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 <div
class="grid p-2 px-4 items-center" class="grid p-2 px-4 items-center"
style="grid-template-columns: 3fr 1fr" style="grid-template-columns: 3fr 1fr"
@@ -7,7 +7,7 @@
<div <div
v-for="column in columns" v-for="column in columns"
:key="column.key" :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 }} {{ column.label }}
</div> </div>
@@ -1,6 +1,6 @@
<template> <template>
<div <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 --> <!-- avatar and name -->
<div class="flex justify-between items-center gap-2"> <div class="flex justify-between items-center gap-2">
@@ -22,7 +22,7 @@
class="-ml-2 grow" class="-ml-2 grow"
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)" 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 class="ml-2">{{ __("Email account name") }}</div>
</div> </div>
<hr class="mx-2 mt-2" /> <hr class="mx-2 mt-2" />
@@ -38,24 +38,13 @@
</div> </div>
</div> </div>
<!-- fallback if no email accounts --> <!-- fallback if no email accounts -->
<div <EmptyState
v-else v-else
class="flex flex-col items-center justify-center gap-4 h-full" variant="badge"
> :icon="EmailIcon"
<div title="No email account found"
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center" description="Add one to get started."
> />
<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>
</template> </template>
</SettingsLayoutBase> </SettingsLayoutBase>
</template> </template>
@@ -65,6 +54,7 @@ import { EmailAccount } from "@/types";
import { createListResource } from "frappe-ui"; import { createListResource } from "frappe-ui";
import EmailAccountCard from "./EmailAccountCard.vue"; import EmailAccountCard from "./EmailAccountCard.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue"; import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { EmailIcon } from "../icons";
const emit = defineEmits(["update:step"]); const emit = defineEmits(["update:step"]);
+7 -5
View File
@@ -27,17 +27,17 @@
<!-- email service provider info --> <!-- email service provider info -->
<div> <div>
<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 <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 }} {{ selectedService.info }}
<a <a
:href="selectedService.link" :href="selectedService.link"
target="_blank" target="_blank"
class="text-blue-500 underline" class="text-ink-blue-2 underline"
>here</a >here</a
>. >.
</div> </div>
@@ -129,7 +129,9 @@
:name="field.name" :name="field.name"
:type="field.type" :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>
</div> </div>
<ErrorMessage v-if="error" class="ml-1" :message="error" /> <ErrorMessage v-if="error" class="ml-1" :message="error" />
+4 -4
View File
@@ -13,12 +13,12 @@
/> />
</div> </div>
<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 <CircleAlert
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-2" 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> <span>
{{ info.description }} {{ info.description }}
<a <a
@@ -122,7 +122,7 @@
:name="field.name" :name="field.name"
:type="field.type" :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>
</div> </div>
<ErrorMessage v-if="error" class="ml-1" :message="error" /> <ErrorMessage v-if="error" class="ml-1" :message="error" />
@@ -405,7 +405,7 @@ function pullEmails() {
toast.create({ toast.create({
message: __("Pulling emails, this may take a few minutes."), 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", { call("frappe.email.doctype.email_account.email_account.pull_emails", {
@@ -17,13 +17,7 @@
{{ props.title }} {{ props.title }}
</h1> </h1>
</div> </div>
<Badge <UnsavedBadge :show="unsavedChanges" />
v-if="unsavedChanges"
:variant="'subtle'"
:theme="'orange'"
size="sm"
:label="__('Unsaved')"
/>
</div> </div>
</template> </template>
<template #header-actions> <template #header-actions>
@@ -71,7 +65,7 @@
:oninput="() => setUnsavedChanges()" :oninput="() => setUnsavedChanges()"
/> />
<div class="flex gap-x-1 items-start justify-between"> <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" "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 ConfirmDialog from "@/components/ConfirmDialog.vue";
import { disableSettingModalOutsideClick } from "../settingsModal"; import { disableSettingModalOutsideClick } from "../settingsModal";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue"; import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
const props = defineProps<{ const props = defineProps<{
title: string; title: string;
@@ -9,41 +9,42 @@
> >
<template #content> <template #content>
<ul class="isolate -ml-3"> <ul class="isolate -ml-3">
<li <div
v-for="notification in notifications" v-for="(notification, index) in notifications"
:key="notification.name" :key="notification.name"
class="flex items-center justify-between p-3 rounded relative"
> >
<div class="flex flex-col gap-1"> <li class="flex items-center justify-between p-3 rounded relative">
<h2 <div class="flex flex-col gap-1">
class="text-base font-medium text-ink-gray-7 relative z-10 pointer-events-none" <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 }} <span class="sr-only">{{
</h2> __("customize {0}", notification.name)
<p }}</span>
class="text-sm text-ink-gray-5 truncate relative z-10 pointer-events-none" </div>
> </li>
{{ notification.description }} <hr v-if="index < notifications.length - 1" class="mx-2" />
</p> </div>
</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>
</ul> </ul>
</template> </template>
</SettingsLayoutBase> </SettingsLayoutBase>
@@ -1,12 +1,12 @@
<template> <template>
<div <div
class="flex size-8 cursor-pointer items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200" 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-blue-500': selected }" :class="{ 'ring-2 ring-outline-blue-3': selected }"
> >
<img v-if="logoValue" :src="logoValue" class="size-4.5" /> <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> </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 }} {{ serviceName }}
</p> </p>
</template> </template>
@@ -10,7 +10,7 @@
@click="handleBackNavigation" @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" 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> </div>
</template> </template>
<template #header-actions> <template #header-actions>
@@ -94,6 +94,7 @@ import FieldDependencyFieldsSelection from "./FieldDependencyFieldsSelection.vue
import FieldDependencyValueSelection from "./FieldDependencyValueSelection.vue"; import FieldDependencyValueSelection from "./FieldDependencyValueSelection.vue";
import { __ } from "@/translation"; import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue"; import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
const props = defineProps({ const props = defineProps({
fieldDependencyName: { fieldDependencyName: {
@@ -10,6 +10,7 @@
:options="parentFields" :options="parentFields"
:disabled="!isNew" :disabled="!isNew"
:open-on-focus="true" :open-on-focus="true"
class="w-full"
/> />
</div> </div>
<div class="flex-1 flex flex-col gap-1.5"> <div class="flex-1 flex flex-col gap-1.5">
@@ -22,6 +23,7 @@
:options="state.childFields" :options="state.childFields"
:disabled="!state.selectedParentField || !isNew" :disabled="!state.selectedParentField || !isNew"
:open-on-focus="true" :open-on-focus="true"
class="w-full"
/> />
</div> </div>
</div> </div>
@@ -42,27 +42,16 @@
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div <EmptyState
v-if=" v-if="
!fieldDependenciesList.loading && !fieldDependenciesList.loading &&
!fieldDependenciesList.data?.length !fieldDependenciesList.data?.length
" "
class="flex flex-col items-center justify-center gap-4 p-4 h-full" variant="badge"
> :icon="FieldDependencyIcon"
<div title="No field dependency found"
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center" description="Add one to get started."
> />
<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>
<div <div
class="w-full -ml-2" class="w-full -ml-2"
@@ -73,7 +62,7 @@
> >
<div> <div>
<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-7 ml-2">{{ __("Name") }}</div>
<div class="col-span-2">{{ __("Created by") }}</div> <div class="col-span-2">{{ __("Created by") }}</div>
@@ -85,7 +74,7 @@
:key="row.name" :key="row.name"
> >
<div <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 <div
@click.stop="$emit('update:step', 'fd', row.name)" @click.stop="$emit('update:step', 'fd', row.name)"
@@ -16,7 +16,7 @@
class="w-full" class="w-full"
> >
<template #prefix> <template #prefix>
<LucideSearch class="h-4 w-4 text-gray-500" /> <LucideSearch class="h-4 w-4 text-ink-gray-4" />
</template> </template>
</FormControl> </FormControl>
<div class="flex-1 overflow-y-auto hide-scrollbar basis-0"> <div class="flex-1 overflow-y-auto hide-scrollbar basis-0">
@@ -77,7 +77,7 @@
class="w-full" class="w-full"
> >
<template #prefix> <template #prefix>
<LucideSearch class="h-4 w-4 text-gray-500" /> <LucideSearch class="h-4 w-4 text-ink-gray-4" />
</template> </template>
</FormControl> </FormControl>
<div class="flex-1 overflow-y-auto hide-scrollbar basis-0"> <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"> <h1 class="text-lg font-semibold text-ink-gray-8">
{{ __("General") }} {{ __("General") }}
</h1> </h1>
<Badge <UnsavedBadge :show="isDirty" />
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved')"
v-if="isDirty || isWebsiteSettingsChanged"
/>
</div> </div>
</template> </template>
<template #header-actions> <template #header-actions>
<Button <Transition name="fade">
:label="__('Save')" <div v-if="isDirty">
variant="solid" <Button
@click="saveSettings" :label="__('Save')"
:loading=" variant="solid"
saveSettingsResource.loading || saveWebsiteSettingsResource.loading @click="saveSettings"
" :loading="
:disabled="!isDirty && !isWebsiteSettingsChanged" saveSettingsResource.loading ||
/> saveWebsiteSettingsResource.loading
"
/>
</div>
</Transition>
</template> </template>
<template #content> <template #content>
<div <div
v-if="settingsDataResource.loading && !settingsDataResource.data" 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" /> <LoadingIndicator class="w-4" />
</div> </div>
@@ -40,7 +38,7 @@
<WorkflowKnowledgebaseSettings /> <WorkflowKnowledgebaseSettings />
<hr class="my-8" /> <hr class="my-8" />
<div> <div>
<div class="text-base font-semibold text-gray-900"> <div class="text-base font-semibold text-ink-gray-9">
{{ __("User Signup") }} {{ __("User Signup") }}
</div> </div>
<div class="flex items-center justify-between mt-6"> <div class="flex items-center justify-between mt-6">
@@ -57,12 +55,18 @@
<Switch v-model="disableSignup" /> <Switch v-model="disableSignup" />
</div> </div>
</div> </div>
<ERPNextIntegrationSettings />
</div> </div>
</template> </template>
</SettingsLayoutBase> </SettingsLayoutBase>
</template> </template>
<script setup lang="ts"> <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 { import {
Badge, Badge,
Button, Button,
@@ -71,14 +75,14 @@ import {
Switch, Switch,
toast, toast,
} from "frappe-ui"; } from "frappe-ui";
import { computed, provide, ref, watch } from "vue";
import { disableSettingModalOutsideClick } from "../settingsModal";
import Branding from "./components/Branding.vue"; import Branding from "./components/Branding.vue";
import TicketSettings from "./components/TicketSettings.vue"; import TicketSettings from "./components/TicketSettings.vue";
import WorkflowKnowledgebaseSettings from "./components/WorkflowKnowledgebaseSettings.vue"; import WorkflowKnowledgebaseSettings from "./components/WorkflowKnowledgebaseSettings.vue";
import { computed, provide, ref, watch } from "vue"; import UnsavedBadge from "@/components/UnsavedBadge.vue";
import { __ } from "@/translation";
import { disableSettingModalOutsideClick } from "../settingsModal"; const configStore = useConfigStore();
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { HDSettings, HDSettingsSymbol } from "@/types";
const isDirty = ref(false); const isDirty = ref(false);
const initialData = ref<null | string>(null); const initialData = ref<null | string>(null);
@@ -166,6 +170,7 @@ const saveSettingsResource = createResource({
onSuccess(data: HDSettings) { onSuccess(data: HDSettings) {
settingsData.value = transformData(data); settingsData.value = transformData(data);
initialData.value = JSON.stringify(settingsData.value); initialData.value = JSON.stringify(settingsData.value);
configStore.configResource.reload();
}, },
}); });
@@ -228,6 +233,18 @@ const saveWebsiteSettingsResource = createResource({
}); });
const saveSettings = async () => { 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 = []; const promises = [];
if (isDirty.value) { if (isDirty.value) {
promises.push(saveSettingsResource.submit()); 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( watch(
settingsData, settingsData,
(data) => { (data) => {
if (!initialData.value) return; if (!initialData.value) return;
isDirty.value = JSON.stringify(data) !== initialData.value; const initial = JSON.parse(initialData.value);
if (isDirty.value) { isDirty.value = Object.keys(data).some(
disableSettingModalOutsideClick.value = true; (key) =>
} else { !(toggleFields as readonly string[]).includes(key) &&
disableSettingModalOutsideClick.value = false; JSON.stringify(data[key as keyof typeof data]) !==
} JSON.stringify(initial[key])
);
disableSettingModalOutsideClick.value = isDirty.value;
}, },
{ deep: true } { 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> </script>
@@ -1,6 +1,6 @@
<template> <template>
<div v-if="settingsData"> <div v-if="settingsData">
<div class="text-base font-semibold text-gray-900"> <div class="text-base font-semibold text-ink-gray-9">
{{ __("Branding") }} {{ __("Branding") }}
</div> </div>
<FormControl <FormControl
@@ -4,7 +4,7 @@
> >
<div class="flex flex-col sm:flex-row items-center gap-3.5"> <div class="flex flex-col sm:flex-row items-center gap-3.5">
<div <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 <Avatar
v-if="props.image" v-if="props.image"
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<div class="text-base font-semibold text-gray-900"> <div class="text-base font-semibold text-ink-gray-9">
{{ __("Ticket Settings") }} {{ __("Ticket Settings") }}
</div> </div>
<div class="mt-6 flex flex-col gap-6"> <div class="mt-6 flex flex-col gap-6">
@@ -224,7 +224,7 @@
v-if="settingsData.enableOutsideHoursBanner" v-if="settingsData.enableOutsideHoursBanner"
class="flex gap-x-1 items-start justify-between" 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" "Find out all of the variables that can be used in the content"
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<div class="text-base font-semibold text-gray-900"> <div class="text-base font-semibold text-ink-gray-9">
{{ __("Workflow & Knowledge Base Settings") }} {{ __("Workflow & Knowledge Base Settings") }}
</div> </div>
<div class="mt-6 flex flex-col gap-6"> <div class="mt-6 flex flex-col gap-6">
@@ -1,12 +1,12 @@
<template> <template>
<div class="w-max mx-auto"> <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 }} {{ formattedMonth }}
</div> </div>
<div class="rounded-md text-sm"> <div class="rounded-md text-sm">
<div class="flex items-center text-xs uppercase"> <div class="flex items-center text-xs uppercase">
<div <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']" v-for="(d, i) in ['s', 'm', 't', 'w', 't', 'f', 's']"
:key="i" :key="i"
> >
@@ -20,7 +20,7 @@
<div <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="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="{ :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)" @mouseover="handleMouseEnter(getFormattedDate(date), open)"
@mouseleave="handleMouseLeave(getFormattedDate(date), close)" @mouseleave="handleMouseLeave(getFormattedDate(date), close)"
@@ -37,7 +37,7 @@
</template> </template>
<template #body-main="{ close: closePopover, open: openPopover }"> <template #body-main="{ close: closePopover, open: openPopover }">
<div <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=" @mouseover="
handleMouseEnter(getFormattedDate(date), openPopover) handleMouseEnter(getFormattedDate(date), openPopover)
" "
@@ -77,7 +77,7 @@
#body-main="{ close: closeDropdown, open: openDropdown }" #body-main="{ close: closeDropdown, open: openDropdown }"
> >
<div <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=" @mouseover="
handleMouseEnter(getFormattedDate(date), openPopover); handleMouseEnter(getFormattedDate(date), openPopover);
handleMouseEnter( handleMouseEnter(
@@ -137,7 +137,7 @@
'text-ink-gray-3': 'text-ink-gray-3':
// @ts-ignore // @ts-ignore
date.getMonth() !== currentMonth - 1 || !isDateInRange(date), 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), getFormattedDate(date) === dateValue && isDateInRange(date),
'opacity-50 cursor-not-allowed': !isDateInRange(date), 'opacity-50 cursor-not-allowed': !isDateInRange(date),
}" }"
@@ -25,7 +25,7 @@
</div> </div>
</div> </div>
<div v-else> <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 class="ml-2">{{ __("Schedule name") }}</div>
</div> </div>
<hr class="mx-2 mt-2" /> <hr class="mx-2 mt-2" />
@@ -1,5 +1,7 @@
<template> <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 <div
class="w-full pl-2 flex flex-col justify-center h-14" class="w-full pl-2 flex flex-col justify-center h-14"
@click="holidayListActiveScreen = { screen: 'view', data: data }" @click="holidayListActiveScreen = { screen: 'view', data: data }"
@@ -10,13 +10,7 @@
@click="goBack()" @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" 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 <UnsavedBadge :show="isDirty" />
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
</div> </div>
</template> </template>
<template #header-actions> <template #header-actions>
@@ -190,7 +184,7 @@
}}</span> }}</span>
</div> </div>
<div class="gap-1 flex items-center"> <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">{{ <span class="text-sm text-ink-gray-6">{{
__("Recurring holidays") __("Recurring holidays")
}}</span> }}</span>
@@ -247,6 +241,7 @@ import HolidaysCalendarView from "./HolidaysCalendarView.vue";
import AddHolidayModal from "./Modals/AddHolidayModal.vue"; import AddHolidayModal from "./Modals/AddHolidayModal.vue";
import { __ } from "@/translation"; import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue"; import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
import { HolidayListResourceSymbol } from "@/types"; import { HolidayListResourceSymbol } from "@/types";
import { HDServiceHolidayList } from "@/types/doctypes"; import { HDServiceHolidayList } from "@/types/doctypes";
@@ -26,7 +26,7 @@
@input="holidaySearchRef = $event" @input="holidaySearchRef = $event"
:placeholder="__('Search')" :placeholder="__('Search')"
type="text" 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" icon-left="search"
debounce="300" debounce="300"
inputClass="p-4 pr-12" inputClass="p-4 pr-12"
@@ -1,5 +1,5 @@
<template> <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="mb-6.5 flex justify-between items-center">
<div class="ml-1"> <div class="ml-1">
<Popover v-if="startYear !== endYear"> <Popover v-if="startYear !== endYear">
@@ -19,7 +19,7 @@
v-for="year in yearsList" v-for="year in yearsList"
:key="year" :key="year"
ref="yearItems" 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)" @click="onYearChange(togglePopover, year)"
> >
{{ year }} {{ year }}
@@ -85,8 +85,8 @@
:class="[ :class="[
'size-1.5 rounded-full cursor-pointer', 'size-1.5 rounded-full cursor-pointer',
{ {
'bg-black': visibleMonths === 'first-half', 'bg-surface-gray-7': visibleMonths === 'first-half',
'bg-gray-400': visibleMonths === 'second-half', 'bg-surface-gray-4': visibleMonths === 'second-half',
}, },
]" ]"
@click="visibleMonths = 'first-half'" @click="visibleMonths = 'first-half'"
@@ -95,8 +95,8 @@
:class="[ :class="[
'size-1.5 rounded-full cursor-pointer', 'size-1.5 rounded-full cursor-pointer',
{ {
'bg-black': visibleMonths === 'second-half', 'bg-surface-gray-7': visibleMonths === 'second-half',
'bg-gray-400': visibleMonths === 'first-half', 'bg-surface-gray-4': visibleMonths === 'first-half',
}, },
]" ]"
@click="visibleMonths = 'second-half'" @click="visibleMonths = 'second-half'"
@@ -1,5 +1,5 @@
<template> <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 <div
class="grid p-2 px-4 items-center gap-2" class="grid p-2 px-4 items-center gap-2"
:style="{ :style="{
@@ -9,7 +9,7 @@
<div <div
v-for="column in columns" v-for="column in columns"
:key="column.key" :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 }} {{ column.label }}
</div> </div>
@@ -30,7 +30,7 @@
:type="'text'" :type="'text'"
placeholder="Description" placeholder="Description"
v-model="holiday[column.key]" 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> <div v-else>
{{ dayjs(holiday[column.key]).format("DD MMM YYYY") }} {{ dayjs(holiday[column.key]).format("DD MMM YYYY") }}
@@ -48,7 +48,7 @@
</div> </div>
<hr v-if="index !== holidays.length - 1" /> <hr v-if="index !== holidays.length - 1" />
</div> </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 No items in the list
</div> </div>
</div> </div>
@@ -1,5 +1,5 @@
<template> <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 <div
class="grid p-2 items-center" class="grid p-2 items-center"
:style="{ :style="{
@@ -10,7 +10,7 @@
<div <div
v-for="column in columns" v-for="column in columns"
:key="column.key" :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 }} {{ column.label }}
</div> </div>
@@ -43,7 +43,7 @@
</div> </div>
<hr class="my-0.5" v-if="index !== holidays.length - 1" /> <hr class="my-0.5" v-if="index !== holidays.length - 1" />
</div> </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 No items in the list
</div> </div>
</div> </div>
@@ -65,7 +65,7 @@
> >
<template #body-content> <template #body-content>
<div v-if="!props.holidayData.from_date || !props.holidayData.to_date"> <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 Please select start and end date first
</div> </div>
</div> </div>
+26 -10
View File
@@ -14,14 +14,20 @@
:debounce="100" :debounce="100"
:description="__('Comma separated emails to invite.')" :description="__('Comma separated emails to invite.')"
/> />
<FormControl <div class="space-y-1.5">
:label="__('Role')" <label class="block text-xs text-ink-gray-5">
type="select" {{ __("Role") }}
:required="true" <span class="text-ink-red-3 select-none" aria-hidden="true">*</span>
:options="roleOptions" </label>
v-model="role" <Select :options="roleOptions" v-model="role" required class="w-full">
:description="roleDescription" <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 <Button
type="submit" type="submit"
variant="solid" variant="solid"
@@ -102,13 +108,22 @@ import { useAuthStore } from "@/stores/auth";
import { capture } from "@/telemetry"; import { capture } from "@/telemetry";
import { __ } from "@/translation"; import { __ } from "@/translation";
import { handleInviteUserSuccess } from "@/utils"; 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 { useOnboarding } from "frappe-ui/frappe";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import LucideChevronDown from "~icons/lucide/chevron-down";
const authStore = useAuthStore(); const authStore = useAuthStore();
const { isAdmin, isManager } = authStore; const { isAdmin, isManager } = authStore;
// @ts-expect-error
const { updateOnboardingStep } = useOnboarding("helpdesk"); const { updateOnboardingStep } = useOnboarding("helpdesk");
const emails = ref(""); const emails = ref("");
@@ -169,7 +184,8 @@ const roleDescription = computed(
const onSubmit = async () => { const onSubmit = async () => {
if (emails.value.trim() === "") { 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({ await inviteByEmailResource.submit({
emails: emails.value, emails: emails.value,
+222 -211
View File
@@ -4,7 +4,7 @@
:description="__('Manage your profile information.')" :description="__('Manage your profile information.')"
> >
<template #content> <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 <FileUploader
:fileTypes="['image/*']" :fileTypes="['image/*']"
@success=" @success="
@@ -18,54 +18,72 @@
<div class="group relative !size-14"> <div class="group relative !size-14">
<Avatar <Avatar
class="!size-14" class="!size-14"
:image="profile.userImage" :image="user.doc?.user_image"
:label="profile.fullName" :label="fullName"
/> />
<component <Tooltip
:is="profile.userImage ? Dropdown : 'div'" :hoverDelay="0"
v-bind=" placement="bottom"
profile.userImage :text="profileTooltipText"
? {
options: [
{
icon: 'upload',
label: profile.userImage
? __('Change image')
: __('Upload image'),
onClick: openFileSelector,
},
{
icon: 'trash-2',
label: __('Remove image'),
onClick: () => updateImage(null),
},
],
}
: { onClick: openFileSelector }
"
> >
<div <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> </div>
</component> </Tooltip>
<div <div
v-if="uploading" 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" /> <LoadingIndicator class="size-4" />
</div> </div>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="flex flex-col"> <div class="flex flex-col gap-1">
<span <div v-if="!editName" class="flex items-end gap-1">
class="text-lg sm:text-xl !font-semibold text-ink-gray-8" <span
>{{ auth?.userName }}</span class="text-lg sm:text-xl !font-semibold text-ink-gray-8"
> >
<span class="text-p-sm text-ink-gray-6">{{ {{ user?.doc?.full_name }}
auth?.user </span>
}}</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> </div>
<ErrorMessage :message="__(_error)" /> <ErrorMessage :message="__(_error)" />
</div> </div>
@@ -73,77 +91,71 @@
</template> </template>
</FileUploader> </FileUploader>
</div> </div>
<hr class="my-6" />
<div> <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="flex gap-2 items-center">
<div class="text-base font-semibold text-ink-gray-9"> <span class="text-base font-semibold text-ink-gray-9">
{{ __("Account & Security") }} {{ __("Account Info & 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> </span>
<span class="text-p-sm text-ink-gray-6">{{ <UnsavedBadge :show="isDirty" />
__("Change your account password for security.")
}}</span>
</div> </div>
<Button
icon-left="lock" <Transition name="fade">
:label="__('Change Password')" <Button
@click="showChangePasswordModal = true" variant="solid"
/> v-if="isDirty"
:label="__('Save')"
:loading="user?.save?.loading"
@click="save()"
/></Transition>
</div> </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 items-center justify-between mt-6">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8"> <span class="text-base font-medium text-ink-gray-8">
{{ __("Language") }} {{ __("Language") }}
</span> </span>
<span class="text-p-sm text-ink-gray-6">{{ <span class="text-p-sm text-ink-gray-6">
__("Change language of the application.") {{ __("Change language of the application.") }}
}}</span> </span>
</div> </div>
<Link <Link
:model-value="language" :model-value="user.doc?.language"
@update:modelValue="language = $event || auth.language" @update:modelValue="updateLanguage"
doctype="Language" doctype="Language"
class="w-40" class="w-40"
/> />
@@ -153,9 +165,9 @@
<span class="text-base font-medium text-ink-gray-8"> <span class="text-base font-medium text-ink-gray-8">
{{ __("Timezone") }} {{ __("Timezone") }}
</span> </span>
<span class="text-p-sm text-ink-gray-6">{{ <span class="text-p-sm text-ink-gray-6">
__("Change timezone of the application.") {{ __("Change timezone of the application.") }}
}}</span> </span>
</div> </div>
<TimezoneControl label="Timezone" v-model="timezone" class="!w-40" /> <TimezoneControl label="Timezone" v-model="timezone" class="!w-40" />
</div> </div>
@@ -175,21 +187,28 @@ import TimezoneControl from "@/components/TimezoneControl.vue";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { __ } from "@/translation"; import { __ } from "@/translation";
import { HDAgent } from "@/types/doctypes"; import { HDAgent } from "@/types/doctypes";
import { computed, nextTick, ref, watch, useTemplateRef } from "vue";
import { import {
Avatar, Avatar,
Badge, Badge,
Button, Button,
createResource,
Dropdown,
FileUploader, FileUploader,
LoadingIndicator, LoadingIndicator,
toast, toast,
createDocumentResource,
createResource,
} from "frappe-ui"; } from "frappe-ui";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import CameraIcon from "~icons/lucide/camera"; import CameraIcon from "~icons/lucide/camera";
import { disableSettingModalOutsideClick } from "../settingsModal"; import { disableSettingModalOutsideClick } from "../settingsModal";
import ChangePasswordModal from "./components/ChangePasswordModal.vue"; 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 auth = useAuthStore();
const profile = ref({ const profile = ref({
fullName: auth.userName, fullName: auth.userName,
@@ -197,130 +216,122 @@ const profile = ref({
firstName: auth.userFirstName, firstName: auth.userFirstName,
lastName: auth.userLastName, lastName: auth.userLastName,
}); });
const showChangePasswordModal = ref(false); const showChangePasswordModal = ref(false);
const language = ref(auth.language); const language = ref(auth.language);
const timezone = ref(auth.timezone); const timezone = ref(auth.timezone);
const isLanguageChanged = computed(() => { const isLanguageChanged = computed(() => {
return language.value !== auth?.language; return language.value !== auth?.language;
},
}); });
const isTimezoneChanged = computed(() => { const { userId } = useAuthStore();
return timezone.value !== auth?.timezone; 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 fullNameRef = useTemplateRef("fullNameRef");
const agentName = agentData.data?.agent_name?.split(" "); const fullName = computed({
if (!agentName) return false; get: () => user.doc?.full_name ?? "",
const isDirty = set: (val) => {
profile.value.firstName !== agentName[0] || if (!user.doc) return;
profile.value.lastName !== (agentName[1] || ""); const [firstName, ...lastName] = val.split(" ");
if (isDirty) { user.doc.first_name = firstName;
disableSettingModalOutsideClick.value = true; user.doc.last_name = lastName.join(" ");
} else { },
disableSettingModalOutsideClick.value = false;
}
return isDirty;
}); });
const agentData = createResource({ function editFullName() {
url: "frappe.client.get", 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, auto: true,
makeParams() { onSuccess(data) {
return { timezoneOptions.value = data.timezones.map((tz) => ({
doctype: "HD Agent", label: tz,
name: auth.userId, value: tz,
}; }));
},
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,
};
}, },
}); });
const setAgent = createResource({ const language = ref(null);
url: "frappe.client.set_value", const timezone = ref(null);
validate: () => {
if (!profile.value.firstName.trim()) { const refreshRequired = ref(false);
return __("Please enter first name at least");
} watch(
}, () => user.doc,
makeParams() { (doc) => {
return { if (!doc) return;
doctype: "HD Agent", if (!language.value) language.value = doc.language;
name: agentData.data?.name, if (!timezone.value) timezone.value = doc.time_zone;
fieldname: {
agent_name: `${profile.value.firstName} ${profile.value.lastName}`,
user_image: profile.value.userImage,
},
};
},
onSuccess: () => {
auth.reloadUser();
agentData.reload();
toast.success(__("Profile updated successfully."));
}, },
{ 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> </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" @input="savedRepliesSearchQuery = $event"
:placeholder="__('Search')" :placeholder="__('Search')"
type="text" 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" icon-left="search"
debounce="300" debounce="300"
inputClass="p-4 pr-12" inputClass="p-4 pr-12"
@@ -51,9 +51,9 @@
</template> </template>
</Button> </Button>
</template> </template>
<template #item="{ item }"> <template #item-label="{ item }">
<button <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" @click="item.onSelect"
> >
<div class="flex items-center justify-between flex-1"> <div class="flex items-center justify-between flex-1">
@@ -74,31 +74,20 @@
<template #content> <template #content>
<div <div
v-if="savedRepliesListResource?.list?.loading" 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" /> <LoadingIndicator class="w-4" />
</div> </div>
<div <EmptyState
v-if=" v-if="
!savedRepliesListResource?.list?.loading && !savedRepliesListResource?.list?.loading &&
!savedRepliesListResource?.data?.length !savedRepliesListResource?.data?.length
" "
class="flex flex-col items-center justify-center gap-4 grow" variant="badge"
> :icon="SavedReplyIcon"
<div title="No saved replies found"
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center" description="Add one to get started."
> />
<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>
<div <div
v-if=" v-if="
!savedRepliesListResource?.list?.loading && !savedRepliesListResource?.list?.loading &&
@@ -107,7 +96,7 @@
class="-ml-2" class="-ml-2"
> >
<div <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-7">{{ __("Title") }}</div>
<div class="col-span-2">{{ __("Owner") }}</div> <div class="col-span-2">{{ __("Owner") }}</div>
@@ -119,7 +108,7 @@
:key="savedReply.name" :key="savedReply.name"
> >
<div <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 <div
@click=" @click="
@@ -128,7 +117,7 @@
data: savedReply, 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 <div
class="text-base text-ink-gray-7 font-medium w-full truncate" class="text-base text-ink-gray-7 font-medium w-full truncate"
@@ -137,7 +126,7 @@
</div> </div>
</div> </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 <Avatar
:label=" :label="
@@ -145,8 +134,11 @@
" "
:image="getUser(savedReply.owner)?.user_image" :image="getUser(savedReply.owner)?.user_image"
size="xs" size="xs"
class="shrink-0"
/> />
{{ getUser(savedReply.owner)?.full_name }} <span class="truncate">{{
getUser(savedReply.owner)?.full_name
}}</span>
</div> </div>
<div <div
class="flex justify-between items-center w-full pr-2 col-span-3" 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 { __ } from "@/translation";
import { ConfirmDelete } from "@/utils"; import { ConfirmDelete } from "@/utils";
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue"; import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
import EmptyState from "@/components/EmptyState.vue";
import { activeFilter } from "./savedReplies"; import { activeFilter } from "./savedReplies";
import { useUserStore } from "../../../stores/user"; import { useUserStore } from "../../../stores/user";
import UserIcon from "~icons/lucide/user"; import UserIcon from "~icons/lucide/user";
@@ -10,13 +10,7 @@
@click="goBack()" @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" 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 <UnsavedBadge :show="isDirty" />
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved changes')"
v-if="isDirty"
/>
</div> </div>
</template> </template>
<template #header-actions> <template #header-actions>
@@ -51,7 +45,7 @@
<template #content> <template #content>
<div <div
v-if="getSavedReplyData.loading" 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" /> <LoadingIndicator class="w-4" />
</div> </div>
@@ -74,6 +68,7 @@
v-model="savedReplyData.scope" v-model="savedReplyData.scope"
:options="scopeDropdownOptions" :options="scopeDropdownOptions"
required required
class="w-full"
> >
<template #prefix> <template #prefix>
<component <component
@@ -81,7 +76,7 @@
class="size-4 text-ink-gray-9" class="size-4 text-ink-gray-9"
/> />
</template> </template>
<template #option="{ option }"> <template #label="{ option }">
<div class="flex gap-2 items-center cursor-pointer"> <div class="flex gap-2 items-center cursor-pointer">
<component :is="option.icon" class="size-4 text-ink-gray-9" /> <component :is="option.icon" class="size-4 text-ink-gray-9" />
<span> <span>
@@ -102,6 +97,7 @@
v-model="savedReplyData.teams" v-model="savedReplyData.teams"
:placeholder="__('Select teams')" :placeholder="__('Select teams')"
@update:modelValue="validateData('teams')" @update:modelValue="validateData('teams')"
class="w-full"
/> />
<div class="text-xs text-ink-gray-5 cursor-default"> <div class="text-xs text-ink-gray-5 cursor-default">
{{ __("Restrict visibility to these teams") }} {{ __("Restrict visibility to these teams") }}
@@ -176,6 +172,7 @@ import { useConfigStore } from "@/stores/config";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { FieldAutocomplete } from "../../../tiptap-extensions"; import { FieldAutocomplete } from "../../../tiptap-extensions";
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue"; import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
import UserIcon from "~icons/lucide/user"; import UserIcon from "~icons/lucide/user";
import UsersIcon from "~icons/lucide/users"; import UsersIcon from "~icons/lucide/users";
import GlobeIcon from "~icons/lucide/globe"; import GlobeIcon from "~icons/lucide/globe";
@@ -268,6 +265,9 @@ const getTeamsListResource = createListResource({
doctype: "HD Team", doctype: "HD Team",
auto: true, auto: true,
fields: ["name"], fields: ["name"],
filters: {
disabled: 0,
},
start: 0, start: 0,
pageLength: 999, pageLength: 999,
transform: (data: Array<Team>) => { transform: (data: Array<Team>) => {
@@ -33,7 +33,7 @@
/> />
<div <div
v-if="getResponsePreviewResource.loading" 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" /> <LoadingIndicator class="size-4" />
</div> </div>
@@ -7,7 +7,7 @@
</h1> </h1>
</slot> </slot>
<slot name="description"> <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 }} {{ description }}
</p> </p>
</slot> </slot>
+15 -10
View File
@@ -5,21 +5,24 @@
:disableOutsideClickToClose="disableSettingModalOutsideClick" :disableOutsideClickToClose="disableSettingModalOutsideClick"
> >
<template #body> <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 <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 <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> </h1>
<div v-for="tab in tabs" class="last:mb-2"> <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.noborder" class="mx-2 my-2.5"></div>
<div <div
v-if="!tab.hideLabel" 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"> <Tooltip :text="__(tab.label)" placement="right">
<span class="truncate">{{ __(tab.label) }}</span> <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="flex h-7 w-full items-center gap-2 rounded px-2 py-[7px]"
:class="[ :class="[
activeTab?.label == item.label activeTab?.label == item.label
? 'bg-white shadow-sm' ? 'bg-surface-selected shadow-sm'
: 'hover:bg-gray-100', : 'hover:bg-surface-gray-2',
]" ]"
@click="() => onTabChange(item)" @click="() => onTabChange(item)"
> >
<component <component
:is="item.icon" :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"> <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) }} {{ __(item.label) }}
</span> </span>
</Tooltip> </Tooltip>
@@ -51,7 +54,9 @@
</nav> </nav>
</div> </div>
</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 <component
:is="activeTab.component" :is="activeTab.component"
v-if="activeTab" v-if="activeTab"
@@ -23,16 +23,16 @@
<template #target="{ togglePopover }" class="w-max"> <template #target="{ togglePopover }" class="w-max">
<div <div
@click="togglePopover()" @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"> <div v-if="priorityData.response_time">
{{ formatTimeHMS(priorityData.response_time) }} {{ formatTimeHMS(priorityData.response_time) }}
</div> </div>
<div v-else class="text-gray-500">Select time</div> <div v-else class="text-ink-gray-4">Select time</div>
</div> </div>
</template> </template>
<template #body> <template #body>
<div class="absolute bg-white top-2"> <div class="absolute bg-surface-white top-2">
<DurationPicker <DurationPicker
v-model="priorityData.response_time" v-model="priorityData.response_time"
:options="{ seconds: false }" :options="{ seconds: false }"
@@ -47,16 +47,16 @@
<template #target="{ togglePopover }" class="w-max"> <template #target="{ togglePopover }" class="w-max">
<div <div
@click="togglePopover()" @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"> <div v-if="priorityData.resolution_time">
{{ formatTimeHMS(priorityData.resolution_time) }} {{ formatTimeHMS(priorityData.resolution_time) }}
</div> </div>
<div v-else class="text-gray-500">Select time</div> <div v-else class="text-ink-gray-4">Select time</div>
</div> </div>
</template> </template>
<template #body> <template #body>
<div class="absolute bg-white top-2"> <div class="absolute bg-surface-white top-2">
<DurationPicker <DurationPicker
v-model="priorityData.resolution_time" v-model="priorityData.resolution_time"
:options="{ seconds: false }" :options="{ seconds: false }"
@@ -44,7 +44,7 @@
value: 'Sunday', value: 'Sunday',
}, },
]" ]"
:class="{ 'border-red-500': errors.workday }" :class="{ 'border-outline-red-3': errors.workday }"
@blur="validateField('workday')" @blur="validateField('workday')"
/> />
<ErrorMessage :message="errors.workday" class="mt-2" /> <ErrorMessage :message="errors.workday" class="mt-2" />
@@ -58,7 +58,7 @@
placeholder="Start Time" placeholder="Start Time"
label="Start Time" label="Start Time"
v-model="workDayData.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')" @blur="validateField('start_time')"
/> />
<ErrorMessage :message="errors.start_time" class="mt-2" /> <ErrorMessage :message="errors.start_time" class="mt-2" />
@@ -72,7 +72,7 @@
placeholder="End Time" placeholder="End Time"
label="End Time" label="End Time"
v-model="workDayData.end_time" v-model="workDayData.end_time"
:class="{ 'border-red-500': errors.end_time }" :class="{ 'border-outline-red-3': errors.end_time }"
@blur="validateTimeRange" @blur="validateTimeRange"
/> />
<ErrorMessage :message="errors.end_time" class="mt-2" /> <ErrorMessage :message="errors.end_time" class="mt-2" />
@@ -7,7 +7,7 @@
/> />
<div <div
v-if="props.conditions.length == 0" 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(['', '', ''])" @click="props.conditions.push(['', '', ''])"
> >
<FeatherIcon name="plus" class="h-4" /> <FeatherIcon name="plus" class="h-4" />
@@ -39,7 +39,7 @@
@input="slaSearchQuery = $event" @input="slaSearchQuery = $event"
:placeholder="__('Search')" :placeholder="__('Search')"
type="text" 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" icon-left="search"
debounce="300" debounce="300"
inputClass="p-4 pr-12" inputClass="p-4 pr-12"
@@ -1,7 +1,7 @@
<template> <template>
<div <div
v-if="slaPolicyList.list.loading && !slaPolicyList.list.data" 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" /> <LoadingIndicator class="w-4" />
</div> </div>
@@ -26,7 +26,7 @@
</div> </div>
<div v-else class="-ml-2"> <div v-else class="-ml-2">
<div <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"> <div class="col-span-5">
{{ __("Policy name") }} {{ __("Policy name") }}
@@ -1,6 +1,6 @@
<template> <template>
<div <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 <div
@click="slaActiveScreen = { screen: 'view', data: data, fetchData: true }" @click="slaActiveScreen = { screen: 'view', data: data, fetchData: true }"
@@ -10,13 +10,7 @@
@click="goBack()" @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" 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 <UnsavedBadge :show="isDirty" />
:variant="'subtle'"
:theme="'orange'"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
</div> </div>
</template> </template>
<template #header-actions> <template #header-actions>
@@ -43,7 +37,7 @@
<template #content> <template #content>
<div <div
v-if="slaData.loading" 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" /> <LoadingIndicator class="w-4" />
</div> </div>
@@ -106,7 +100,7 @@
</template> </template>
<template #body-main> <template #body-main>
<div <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> <code>{{ slaData.condition }}</code>
</div> </div>
@@ -116,7 +110,7 @@
</div> </div>
<div class="mt-5" v-if="!slaData.default_sla"> <div class="mt-5" v-if="!slaData.default_sla">
<div <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" v-if="!useNewUI"
> >
<span class="text-p-sm"> <span class="text-p-sm">
@@ -299,6 +293,7 @@ import { disableSettingModalOutsideClick } from "../settingsModal";
import { useOnboarding } from "frappe-ui/frappe"; import { useOnboarding } from "frappe-ui/frappe";
import { __ } from "@/translation"; import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue"; import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
import { SlaPolicyListResourceSymbol } from "@/types"; import { SlaPolicyListResourceSymbol } from "@/types";
import { HDServiceLevelAgreement } from "@/types/doctypes"; import { HDServiceLevelAgreement } from "@/types/doctypes";
@@ -1,5 +1,5 @@
<template> <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 <div
class="grid p-2 px-4 items-center" class="grid p-2 px-4 items-center"
:style="{ :style="{
@@ -10,7 +10,7 @@
<div <div
v-for="column in columns" v-for="column in columns"
:key="column.key" :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="{ :class="{
'ml-2': 'ml-2':
column.key === 'priority' || column.key === 'priority' ||
@@ -19,7 +19,7 @@
}" }"
> >
{{ column.label }} {{ 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>
</div> </div>
<hr v-if="slaData.priorities?.length !== 0" /> <hr v-if="slaData.priorities?.length !== 0" />
@@ -32,7 +32,7 @@
/> />
<div <div
v-if="slaData.priorities?.length === 0" 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 No priorities in the list
</div> </div>
@@ -61,23 +61,26 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Button, createResource, toast } from "frappe-ui";
import SlaPriorityListItem from "./SlaPriorityListItem.vue";
import { computed, provide, reactive } from "vue";
import { import {
slaActiveScreen, slaActiveScreen,
slaData, slaData,
slaDataErrors, slaDataErrors,
validateSlaData, validateSlaData,
} from "@/stores/sla"; } from "@/stores/sla";
import { watchDebounced } from "@vueuse/core";
import { getGridTemplateColumnsForTable } from "@/utils"; 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({ createResource({
url: "frappe.client.get_list", url: "frappe.client.get_list",
params: { params: {
doctype: "HD Ticket Priority", doctype: "HD Ticket Priority",
fields: ["name"], fields: ["name"],
filters: {
disabled: 0,
},
order_by: "integer_value desc", order_by: "integer_value desc",
}, },
auto: true, auto: true,
@@ -25,13 +25,13 @@
<template #target="{ togglePopover }"> <template #target="{ togglePopover }">
<div <div
@click="togglePopover()" @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]) }} {{ formatTimeHMS(props.row[column.key]) }}
</div> </div>
</template> </template>
<template #body> <template #body>
<div class="absolute bg-white top-2"> <div class="absolute bg-surface-white top-2">
<DurationPicker v-model="props.row[column.key]" /> <DurationPicker v-model="props.row[column.key]" />
</div> </div>
</template> </template>
@@ -1,5 +1,5 @@
<template> <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 <div
class="grid p-2 px-4 items-center" class="grid p-2 px-4 items-center"
:style="{ :style="{
@@ -10,13 +10,13 @@
<div <div
v-for="column in columns" v-for="column in columns"
:key="column.key" :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="{ :class="{
'ml-2': column.key === 'workday', 'ml-2': column.key === 'workday',
}" }"
> >
{{ column.label }} {{ 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>
</div> </div>
<hr v-if="slaData.support_and_resolution?.length !== 0" /> <hr v-if="slaData.support_and_resolution?.length !== 0" />
@@ -29,7 +29,7 @@
/> />
<div <div
v-if="slaData.support_and_resolution?.length === 0" 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 No workdays in the list
</div> </div>
+27 -15
View File
@@ -10,23 +10,25 @@
@click="goBack()" @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" 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 <UnsavedBadge :show="isDirty" />
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
</div> </div>
</template> </template>
<template #header-actions> <template #header-actions>
<Button <div class="flex items-center gap-4">
:label="__('Save')" <div class="flex items-center gap-2 cursor-pointer">
variant="solid" <Switch v-model="teamData.enabled" />
@click="saveTeam()" <span class="text-sm text-ink-gray-7 font-medium">
:disabled="!isDirty" {{ __("Enabled") }}
:loading="teamsList.insert.loading" </span>
/> </div>
<Button
:label="__('Save')"
variant="solid"
@click="saveTeam()"
:disabled="!isDirty"
:loading="teamsList.insert.loading"
/>
</div>
</template> </template>
<template #content> <template #content>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
@@ -65,7 +67,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, onMounted, ref } from "vue"; import { computed, inject, onMounted, ref } from "vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.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 { __ } from "@/translation";
import AgentSelector from "./components/AgentSelector.vue"; import AgentSelector from "./components/AgentSelector.vue";
import { useAgentStore } from "@/stores/agent"; import { useAgentStore } from "@/stores/agent";
@@ -94,6 +104,7 @@ const showConfirmDialog = ref({
const teamData = ref({ const teamData = ref({
name: "", name: "",
agents: [], agents: [],
enabled: true,
}); });
const errors = ref({ const errors = ref({
@@ -132,6 +143,7 @@ const saveTeam = () => {
{ {
team_name: teamData.value.name, team_name: teamData.value.name,
users: teamData.value.agents.map((agent) => ({ user: agent })), users: teamData.value.agents.map((agent) => ({ user: agent })),
disabled: !teamData.value.enabled,
}, },
{ {
onSuccess: (data) => { onSuccess: (data) => {
@@ -10,6 +10,7 @@
<template #actions> <template #actions>
<Button <Button
variant="solid" variant="solid"
class="flex w-full"
@click="renameTeam" @click="renameTeam"
:loading="renameTeamResource.loading" :loading="renameTeamResource.loading"
:disabled="teamName == dialog.teamName || teamName.trim() == ''" :disabled="teamName == dialog.teamName || teamName.trim() == ''"
+85 -154
View File
@@ -15,13 +15,21 @@
</div> </div>
</template> </template>
<template #header-actions> <template #header-actions>
<Dropdown placement="right" :options="options"> <div class="flex items-center gap-4">
<Button variant="ghost"> <div class="flex items-center justify-between gap-2 cursor-pointer">
<template #icon> <Switch v-model="teamEnabled" v-if="!team.loading" />
<LucideMoreHorizontal class="h-4 w-4" /> <span class="text-sm text-ink-gray-7 font-medium">
</template> {{ __("Enabled") }}
</Button> </span>
</Dropdown> </div>
<Dropdown placement="right" :options="options">
<Button variant="ghost">
<template #icon>
<LucideMoreHorizontal class="h-4 w-4" />
</template>
</Button>
</Dropdown>
</div>
</template> </template>
<template #header-bottom> <template #header-bottom>
<!-- Add member --> <!-- Add member -->
@@ -52,7 +60,9 @@
</template> </template>
<template #content> <template #content>
<div class="w-full h-full" v-if="teamMembers?.length > 0"> <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"> <div class="col-span-6 text-p-sm">
{{ __("Members ({0})", teamMembers.length) }} {{ __("Members ({0})", teamMembers.length) }}
</div> </div>
@@ -64,6 +74,7 @@
<AgentCard :agent="member" class="!py-0"> <AgentCard :agent="member" class="!py-0">
<template #right> <template #right>
<Dropdown <Dropdown
v-if="teamMembers.length > 1"
:options="memberDropdownOptions(member)" :options="memberDropdownOptions(member)"
placement="right" placement="right"
> >
@@ -100,40 +111,15 @@
</div> </div>
</template> </template>
</SettingsLayoutBase> </SettingsLayoutBase>
<Dialog v-model="showDelete" :options="{ title: 'Delete team' }"> <RenameTeamModal
<template #body-content> v-model="showRename"
<p class="text-p-base text-ink-gray-7"> @onRename="
{{ () => {
__( teamsList.reload();
"Are you sure you want to delete this team? This action cannot be reversed!" emit('update:step', 'team-list');
) }
}} "
</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>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -148,13 +134,12 @@ import { ConfirmDelete } from "@/utils";
import { import {
Button, Button,
createDocumentResource, createDocumentResource,
createResource,
Dialog,
Dropdown, Dropdown,
Switch,
toast, toast,
Tooltip,
} from "frappe-ui"; } from "frappe-ui";
import { computed, h, inject, markRaw, onMounted, ref } from "vue"; import { computed, h, inject, markRaw, onMounted, ref } from "vue";
import RenameTeamModal from "./RenameTeamModal.vue";
import LucideLock from "~icons/lucide/lock"; import LucideLock from "~icons/lucide/lock";
import Settings from "~icons/lucide/settings-2"; import Settings from "~icons/lucide/settings-2";
import LucideUnlock from "~icons/lucide/unlock"; import LucideUnlock from "~icons/lucide/unlock";
@@ -179,7 +164,6 @@ const teamsList = inject(TeamListResourceSymbol);
const { teamRestrictionApplied } = useConfigStore(); const { teamRestrictionApplied } = useConfigStore();
const invitees = ref<string[]>([]); const invitees = ref<string[]>([]);
const _teamName = ref(props.teamName);
const team = createDocumentResource({ const team = createDocumentResource({
doctype: "HD Team", doctype: "HD Team",
name: props.teamName, 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({ const ignoreRestrictions = computed({
get() { get() {
return !!team.doc?.ignore_restrictions; return !!team.doc?.ignore_restrictions;
@@ -231,52 +240,14 @@ function addMember(users: string[]) {
invitees.value = []; invitees.value = [];
} }
const showRename = ref(false); const showRename = ref({
show: false,
teamName: props.teamName,
});
const renameDialogOptions = { const isConfirmingTeamDelete = ref(false);
title: __("Rename team"),
message: __("Enter the new name for the team"),
actions: [
{
label: __("Confirm"),
variant: "solid",
loading: team.loading,
onClick: ({ close }) => {
renameTeam(close);
},
},
],
};
function renameTeam(close) { const options = computed(() => [
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 = [
{ {
label: __("View Assignment rule"), label: __("View Assignment rule"),
icon: markRaw(h(Settings, { class: "rotate-90" })), icon: markRaw(h(Settings, { class: "rotate-90" })),
@@ -291,73 +262,33 @@ const options = [
{ {
label: __("Rename"), label: __("Rename"),
icon: "edit-3", icon: "edit-3",
onClick: () => (showRename.value = !showRename.value), onClick: () => {
showRename.value = { show: true, teamName: props.teamName };
},
}, },
{ ...(teamRestrictionApplied
condition: () => teamRestrictionApplied, ? [
label: team.doc?.ignore_restrictions
? __("Disable Bypass Restrictions")
: __("Enable Bypass Restrictions"),
component: () =>
h(
Tooltip,
{ {
text: ignoreRestrictions.value label: ignoreRestrictions.value
? __( ? __("Access only this team's tickets")
"Members of this team will see the tickets assigned to this team only" : __("Access all team tickets"),
) icon: markRaw(ignoreRestrictions.value ? LucideLock : LucideUnlock),
: __( onClick: () => {
"Members of this team will be able to see the tickets assigned to all the teams" 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 ...ConfirmDelete({
h( isConfirmingDelete: isConfirmingTeamDelete,
"div", onConfirmDelete: () => team.delete.submit(),
{ }),
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;
},
}),
},
];
const isConfirmingDelete = ref(false); const isConfirmingDelete = ref(false);
@@ -29,7 +29,7 @@ const teamsSearchQuery = ref("");
const teams = createListResource({ const teams = createListResource({
doctype: "HD Team", doctype: "HD Team",
cache: ["Teams"], cache: ["Teams"],
fields: ["name"], fields: ["name", "disabled"],
auto: true, auto: true,
orderBy: "modified desc", orderBy: "modified desc",
start: 0, start: 0,
@@ -24,7 +24,7 @@
@input="teamsSearchQuery = $event" @input="teamsSearchQuery = $event"
:placeholder="__('Search')" :placeholder="__('Search')"
type="text" 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" icon-left="search"
debounce="300" debounce="300"
inputClass="p-4 pr-12" inputClass="p-4 pr-12"
@@ -44,21 +44,22 @@
v-if="!teams.loading && teams.data?.length > 0" v-if="!teams.loading && teams.data?.length > 0"
class="w-full h-full -ml-2" class="w-full h-full -ml-2"
> >
<div class="flex text-sm text-gray-600"> <div class="flex text-sm text-ink-gray-5">
<div class="ml-2">{{ __("Team name") }}</div> <p class="ml-2">{{ __("Team name") }}</p>
</div> </div>
<hr class="mx-2 mt-2" /> <hr class="mx-2 mt-2" />
<div v-for="(team, index) in teams.data" :key="team.name"> <div v-for="(team, index) in teams.data" :key="team.name">
<div <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 <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)" @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 }} {{ team.name }}
</div> </p>
<Badge :label="__('Disabled')" v-if="team.disabled" />
</div> </div>
<div class="flex justify-between items-center pr-2"> <div class="flex justify-between items-center pr-2">
<div> <div>
@@ -99,28 +100,17 @@
/> />
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div <EmptyState
v-if="!teams.loading && !teams.data?.length" v-if="!teams.loading && !teams.data?.length"
class="flex flex-col items-center justify-center gap-4 h-full" variant="badge"
> :icon="AgentIcon"
<div title="No team found"
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center" :description="
> teamsSearchQuery.length
<AgentIcon class="size-6 text-ink-gray-6" /> ? 'Change your search terms to find teams.'
</div> : 'Add one to get started.'
<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>
</template> </template>
</SettingsLayoutBase> </SettingsLayoutBase>
<NewTeamModal <NewTeamModal
@@ -135,15 +125,17 @@
</template> </template>
<script setup lang="ts"> <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 { Dropdown, Input, toast } from "frappe-ui";
import { inject, markRaw, Ref, ref, watch } from "vue"; import { inject, markRaw, Ref, ref, watch } from "vue";
import NewTeamModal from "../NewTeamModal.vue"; import NewTeamModal from "../NewTeamModal.vue";
import { ConfirmDelete } from "@/utils";
import EditIcon from "@/components/icons/EditIcon.vue";
import RenameTeamModal from "./RenameTeamModal.vue"; import RenameTeamModal from "./RenameTeamModal.vue";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { TeamListResourceSymbol } from "@/types";
interface E { interface E {
(event: "update:step", step: string, team: string): void; (event: "update:step", step: string, team: string): void;
@@ -152,7 +144,7 @@ interface E {
const emit = defineEmits<E>(); const emit = defineEmits<E>();
const teamsSearchQuery = inject<Ref>("teamsSearchQuery"); const teamsSearchQuery = inject<Ref>("teamsSearchQuery");
const teams = inject(TeamListResourceSymbol); const teams = inject(TeamListResourceSymbol)!;
const showForm = ref(false); const showForm = ref(false);
const showRename = ref({ const showRename = ref({
show: false, show: false,
@@ -12,16 +12,7 @@
@click="goBack" @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" 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 <UnsavedBadge :show="isDirty.exotel" />
:class="[
isDirty.twilio || isDirty.exotel || isDirty.telephonyAgent
? 'opacity-100'
: 'opacity-0',
]"
:label="__('Unsaved')"
theme="orange"
variant="subtle"
/>
</div> </div>
</template> </template>
<template #header-actions> <template #header-actions>
@@ -30,14 +21,9 @@
theme="gray" theme="gray"
variant="solid" variant="solid"
@click="save" @click="save"
:disabled=" v-if="isDirty.exotel"
!isDirty.twilio && !isDirty.exotel && !isDirty.telephonyAgent :disabled="!isDirty.exotel"
" :loading="exotel.save.loading"
:loading="
twilio.save.loading ||
exotel.save.loading ||
telephonyAgent.save.loading
"
/> />
</template> </template>
<template #content> <template #content>
@@ -158,6 +144,7 @@ import { useAuthStore } from "@/stores/auth";
import { useTelephonyStore } from "@/stores/telephony"; import { useTelephonyStore } from "@/stores/telephony";
import { disableSettingModalOutsideClick } from "../settingsModal"; import { disableSettingModalOutsideClick } from "../settingsModal";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue"; import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue"; import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { __ } from "@/translation"; import { __ } from "@/translation";
@@ -5,40 +5,37 @@
<h1 class="text-lg font-semibold text-ink-gray-8"> <h1 class="text-lg font-semibold text-ink-gray-8">
{{ __("Telephony") }} {{ __("Telephony") }}
</h1> </h1>
<Badge <UnsavedBadge
:class="[ :show="isDirty.twilio || isDirty.exotel || isDirty.telephonyAgent"
isDirty.twilio || isDirty.exotel || isDirty.telephonyAgent
? 'opacity-100'
: 'opacity-0',
]"
:label="__('Unsaved')"
theme="orange"
variant="subtle"
/> />
</div> </div>
</template> </template>
<template #header-actions> <template #header-actions>
<Button <Transition name="fade">
:label="__('Save')" <div v-if="isDirty.twilio || isDirty.exotel || isDirty.telephonyAgent">
theme="gray" <Button
variant="solid" :label="__('Save')"
@click="save" theme="gray"
:disabled=" variant="solid"
!isDirty.twilio && !isDirty.exotel && !isDirty.telephonyAgent @click="save"
" :disabled="
:loading=" !isDirty.twilio && !isDirty.exotel && !isDirty.telephonyAgent
twilio.save.loading || "
exotel.save.loading || :loading="
telephonyAgent.save.loading twilio.save.loading ||
" exotel.save.loading ||
/> telephonyAgent.save.loading
"
/>
</div>
</Transition>
</template> </template>
<template #content> <template #content>
<div class="-ml-2 grow"> <div class="-ml-2 grow">
<div class="flex-1 flex flex-col"> <div class="flex-1 flex flex-col">
<!-- General --> <!-- General -->
<div <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="flex flex-col">
<div class="text-p-base font-medium text-ink-gray-7 truncate"> <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="h-px border-t mx-2 border-outline-gray-modals" />
<div <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')" @click="emit('updateStep', 'twilio-settings')"
> >
<div class="flex flex-col"> <div class="flex flex-col">
@@ -88,7 +85,7 @@
<div class="h-px border-t mx-2 border-outline-gray-modals" /> <div class="h-px border-t mx-2 border-outline-gray-modals" />
<div <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')" @click="emit('updateStep', 'exotel-settings')"
> >
<div class="flex flex-col"> <div class="flex flex-col">
@@ -135,6 +132,7 @@ import { useTelephonyStore } from "@/stores/telephony";
import { disableSettingModalOutsideClick } from "../settingsModal"; import { disableSettingModalOutsideClick } from "../settingsModal";
import { __ } from "@/translation"; import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue"; import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
const auth = useAuthStore(); const auth = useAuthStore();
const telephonyStore = useTelephonyStore(); const telephonyStore = useTelephonyStore();
@@ -12,12 +12,7 @@
@click="goBack" @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" 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 <UnsavedBadge :show="isDirty.twilio" />
:class="[isDirty.twilio ? 'opacity-100' : 'opacity-0']"
:label="__('Unsaved')"
theme="orange"
variant="subtle"
/>
</div> </div>
</template> </template>
<template #header-actions> <template #header-actions>
@@ -26,6 +21,7 @@
theme="gray" theme="gray"
variant="solid" variant="solid"
@click="save" @click="save"
v-if="isDirty.twilio"
:disabled="!isDirty.twilio" :disabled="!isDirty.twilio"
:loading="twilio.save.loading" :loading="twilio.save.loading"
/> />
@@ -130,6 +126,7 @@
<script setup> <script setup>
import Password from "@/components/Password.vue"; import Password from "@/components/Password.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue"; import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue"; import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { import {
Button, Button,
@@ -1,4 +1,5 @@
export const isDocDirty = (doc: any, originalDoc: any) => { export const isDocDirty = (doc: any, originalDoc: any) => {
if (!doc || !originalDoc) return false;
return JSON.stringify(doc) !== JSON.stringify(originalDoc); return JSON.stringify(doc) !== JSON.stringify(originalDoc);
}; };
@@ -20,12 +20,12 @@ import TelephonyPage from "./Telephony/TelephonyPage.vue";
import { EmailNotifications } from "./EmailNotifications"; import { EmailNotifications } from "./EmailNotifications";
import { __ } from "@/translation"; import { __ } from "@/translation";
import SavedReplies from "./SavedReplies/SavedReplies.vue"; import SavedReplies from "./SavedReplies/SavedReplies.vue";
import Profile from "./Profile/Profile.vue";
import { Avatar } from "frappe-ui"; import { Avatar } from "frappe-ui";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import General from "./General/General.vue"; import General from "./General/General.vue";
import SettingsGear from "~icons/lucide/settings"; import SettingsGear from "~icons/lucide/settings";
import SavedReplyIcon from "../icons/SavedReplyIcon.vue"; import SavedReplyIcon from "../icons/SavedReplyIcon.vue";
import ProfilePage from "./Profile/ProfilePage.vue";
export const showSettingsModal = ref(false); export const showSettingsModal = ref(false);
@@ -45,8 +45,8 @@ export const tabs = computed(() => {
label: auth.userName, label: auth.userName,
size: "xs", size: "xs",
}), }),
component: markRaw(Profile), component: markRaw(ProfilePage),
}, }
], ],
}, },
{ {
+7 -7
View File
@@ -1,6 +1,6 @@
<template> <template>
<div <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="{ :class="{
'w-auto': isExpanded, 'w-auto': isExpanded,
'w-8': !isExpanded, 'w-8': !isExpanded,
@@ -12,9 +12,9 @@
> >
<Tooltip :text="__(label)" v-if="!isExpanded"> <Tooltip :text="__(label)" v-if="!isExpanded">
<span <span
class="shrink-0 text-gray-700" class="shrink-0 text-ink-gray-7"
:class="{ :class="{
'text-gray-900': !isExpanded, 'text-ink-gray-9': !isExpanded,
'icon-emoji': isMobileView, 'icon-emoji': isMobileView,
}" }"
> >
@@ -23,9 +23,9 @@
</Tooltip> </Tooltip>
<span <span
v-else v-else
class="shrink-0 text-gray-700" class="shrink-0 text-ink-gray-7"
:class="{ :class="{
'text-gray-900': !isExpanded, 'text-ink-gray-9': !isExpanded,
'icon-emoji': isMobileView, 'icon-emoji': isMobileView,
}" }"
> >
@@ -67,8 +67,8 @@ const props = withDefaults(defineProps<P>(), {
isActive: false, isActive: false,
onClick: () => () => true, onClick: () => () => true,
to: "", to: "",
bgColor: "bg-white", bgColor: "bg-surface-selected",
hvColor: "hover:bg-gray-100", hvColor: "hover:bg-surface-gray-2",
}); });
const router = useRouter(); const router = useRouter();
const { isMobileView } = useScreenSize(); const { isMobileView } = useScreenSize();
+6 -2
View File
@@ -11,7 +11,7 @@
<div <div
v-for="i in numberCardsCount" v-for="i in numberCardsCount"
:key="`card-${i}`" :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-3 w-1/2 bg-surface-gray-3 rounded" />
<div class="h-7 w-2/3 bg-surface-gray-1 rounded" /> <div class="h-7 w-2/3 bg-surface-gray-1 rounded" />
@@ -78,7 +78,11 @@
<!-- empty state --> <!-- empty state -->
<div <div
v-if="showVariant('empty-state')" 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 <div
class="relative z-10 text-ink-gray-7 font-medium text-center text-p-base leading-[1.15]" 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"> <div class="rounded p-3 shadow w-full">
<FTextEditor <FTextEditor
ref="e" ref="e"
:extensions="[ComponentUtils, HandleExcelPaste]" :extensions="[ComponentUtils, HandleExcelPaste, CleanStyles]"
v-bind="$attrs" v-bind="$attrs"
:editor-class="[ :editor-class="[
'prose-f max-h-64 max-w-none overflow-auto my-4 min-h-[5rem]', '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" class="flex flex-col space-y-1.5 overflow-auto sm:flex-row sm:justify-between"
> >
<div class="flex items-center"> <div class="flex items-center">
<TextEditorFixedMenu :buttons="fixedMenu" />
<slot name="bottom-left" /> <slot name="bottom-left" />
<TextEditorFixedMenu :buttons="fixedMenu" />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button <Button
label="Discard" label="Discard"
theme="gray" theme="gray"
variant="subtle" variant="subtle"
v-if="!isContentEmpty(modelValue)"
@click=" @click="
() => { () => {
editor.commands.clearContent(true); editor.commands.clearContent(true);
@@ -59,8 +60,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { UserAvatar } from "@/components"; import { UserAvatar } from "@/components";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { ComponentUtils, HandleExcelPaste } from "@/tiptap-extensions"; import {
import { getFontFamily } from "@/utils"; CleanStyles,
ComponentUtils,
HandleExcelPaste,
} from "@/tiptap-extensions";
import { ClearFormattingUtility, getFontFamily, isContentEmpty } from "@/utils";
import { TextEditor as FTextEditor, TextEditorFixedMenu } from "frappe-ui"; import { TextEditor as FTextEditor, TextEditorFixedMenu } from "frappe-ui";
import { computed, nextTick, ref } from "vue"; import { computed, nextTick, ref } from "vue";
@@ -86,13 +91,19 @@ const authStore = useAuthStore();
const fixedMenu = [ const fixedMenu = [
"Paragraph", "Paragraph",
["Heading 2", "Heading 3", "Heading 4", "Heading 5"], ["Heading 2", "Heading 3", "Heading 4", "Heading 5"],
"Separator",
"Bold",
"Italic",
"Separator",
"Bullet List", "Bullet List",
"Numbered List", "Numbered List",
"Separator",
"Image", "Image",
"Video", "Video",
"Link", "Link",
"Blockquote", "Blockquote",
"Code", "Code",
ClearFormattingUtility,
]; ];
defineExpose({ defineExpose({
+6 -7
View File
@@ -1,10 +1,10 @@
<template> <template>
<div class="flex gap-2 px-5 pb-1 leading-5 first:mt-3 items-center"> <div class="flex gap-2 pb-1 leading-5 items-center">
<div class="w-[106px] shrink-0 truncate text-sm text-gray-600"> <div class="w-[106px] shrink-0 truncate text-sm text-ink-gray-5">
<Tooltip :text="field.label"> <Tooltip :text="field.label">
<span>{{ field.label }}</span> <span>{{ field.label }}</span>
</Tooltip> </Tooltip>
<span v-if="field.required" class="text-red-500"> * </span> <span v-if="field.required" class="text-ink-red-3"> * </span>
</div> </div>
<div <div
class="-m-0.5 min-h-[28px] flex-1 items-center overflow-hidden p-0.5 text-base" 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)) { } else if (textFields.includes(props.field.fieldtype)) {
return h(FormControl, { return h(FormControl, {
type: "textarea", type: "text",
rows: 1,
}); });
} else if (props.field.fieldtype === "Datetime") { } else if (props.field.fieldtype === "Datetime") {
return h(DateTimePicker, { return h(DateTimePicker, {
@@ -158,7 +157,7 @@ function emitUpdate(fieldname: Field["fieldname"], value: FieldValue) {
:deep(.form-control textarea), :deep(.form-control textarea),
:deep(.form-control button) { :deep(.form-control button) {
border-color: transparent; border-color: transparent;
background: white; background: var(--surface-white);
} }
:deep(.form-control button) { :deep(.form-control button) {
@@ -176,7 +175,7 @@ function emitUpdate(fieldname: Field["fieldname"], value: FieldValue) {
} }
:deep(.form-control button svg) { :deep(.form-control button svg) {
color: white; color: var(--ink-white);
width: 0; width: 0;
} }
</style> </style>

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