Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d54f36aa19 | ||
|
|
7aad3c9c6b | ||
|
|
c6113852d7 | ||
|
|
94e930a141 | ||
|
|
d739e6e6e8 | ||
|
|
2d320bff17 | ||
|
|
d883d5d58e | ||
|
|
0fe9a3a5e0 | ||
|
|
7fe94dc1a2 | ||
|
|
cae097c5fa | ||
|
|
faf35738b3 | ||
|
|
341487b93e | ||
|
|
f569ed93ad | ||
|
|
eac292b11b | ||
|
|
75119d4a08 | ||
|
|
67d7ee0185 | ||
|
|
fe1c29a15e | ||
|
|
081a812243 | ||
|
|
9031c710b7 | ||
|
|
5ce8533e9f | ||
|
|
7e33e033cd | ||
|
|
857c8d90d2 | ||
|
|
d5d13792a8 | ||
|
|
02fa76f3df | ||
|
|
91d80004a6 | ||
|
|
b809cd2f15 |
@@ -55,4 +55,21 @@
|
||||
|
||||
## Ruby Best Practices
|
||||
|
||||
- Use compact `module/class` definitions; avoid nested styles
|
||||
- Use compact `module/class` definitions; avoid nested styles
|
||||
|
||||
## Enterprise Edition Notes
|
||||
|
||||
- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code.
|
||||
- When you add or modify core functionality, always check for corresponding files in `enterprise/` and keep behavior compatible.
|
||||
- Follow the Enterprise development practices documented here:
|
||||
- https://chatwoot.help/hc/handbook/articles/developing-enterprise-edition-features-38
|
||||
|
||||
Practical checklist for any change impacting core logic or public APIs
|
||||
- Search for related files in both trees before editing (e.g., `rg -n "FooService|ControllerName|ModelName" app enterprise`).
|
||||
- If adding new endpoints, services, or models, consider whether Enterprise needs:
|
||||
- An override (e.g., `enterprise/app/...`), or
|
||||
- An extension point (e.g., `prepend_mod_with`, hooks, configuration) to avoid hard forks.
|
||||
- Avoid hardcoding instance- or plan-specific behavior in OSS; prefer configuration, feature flags, or extension points consumed by Enterprise.
|
||||
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||
|
||||
@@ -108,7 +108,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
|
||||
##-- apm and error monitoring ---#
|
||||
# loaded only when environment variables are set.
|
||||
# ref application.rb
|
||||
gem 'ddtrace', require: false
|
||||
gem 'datadog', '~> 2.0', require: false
|
||||
gem 'elastic-apm', require: false
|
||||
gem 'newrelic_rpm', require: false
|
||||
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
||||
|
||||
+18
-4
@@ -194,10 +194,14 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.4.1)
|
||||
ddtrace (0.48.0)
|
||||
ffi (~> 1.0)
|
||||
datadog (2.19.0)
|
||||
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
|
||||
libdatadog (~> 18.1.0.1.0)
|
||||
libddwaf (~> 1.24.1.0.3)
|
||||
logger
|
||||
msgpack
|
||||
datadog-ruby_core_source (3.4.1)
|
||||
date (3.4.1)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
@@ -444,6 +448,16 @@ GEM
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
libdatadog (18.1.0.1.0)
|
||||
libdatadog (18.1.0.1.0-x86_64-linux)
|
||||
libddwaf (1.24.1.0.3)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-arm64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-x86_64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-x86_64-linux)
|
||||
ffi (~> 1.0)
|
||||
line-bot-api (1.28.0)
|
||||
lint_roller (1.1.0)
|
||||
liquid (5.4.0)
|
||||
@@ -928,7 +942,7 @@ DEPENDENCIES
|
||||
commonmarker
|
||||
csv-safe
|
||||
database_cleaner
|
||||
ddtrace
|
||||
datadog (~> 2.0)
|
||||
debug (~> 1.8)
|
||||
devise (>= 4.9.4)
|
||||
devise-secure_password!
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_assignment_policy
|
||||
before_action -> { check_authorization(AssignmentPolicy) }
|
||||
|
||||
def index
|
||||
@inboxes = @assignment_policy.inboxes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(
|
||||
params[:assignment_policy_id]
|
||||
)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:assignment_policy_id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@assignment_policies = Current.account.assignment_policies
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params)
|
||||
end
|
||||
|
||||
def update
|
||||
@assignment_policy.update!(assignment_policy_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@assignment_policy.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(params[:id])
|
||||
end
|
||||
|
||||
def assignment_policy_params
|
||||
params.require(:assignment_policy).permit(
|
||||
:name, :description, :assignment_order, :conversation_priority,
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
class Api::V1::Accounts::Inboxes::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_inbox
|
||||
before_action :fetch_assignment_policy, only: [:create]
|
||||
before_action -> { check_authorization(AssignmentPolicy) }
|
||||
before_action :validate_assignment_policy, only: [:show, :destroy]
|
||||
|
||||
def show
|
||||
@assignment_policy = @inbox.assignment_policy
|
||||
end
|
||||
|
||||
def create
|
||||
# There should be only one assignment policy for an inbox.
|
||||
# If there is a new request to add an assignment policy, we will
|
||||
# delete the old one and attach the new policy
|
||||
remove_inbox_assignment_policy
|
||||
@inbox_assignment_policy = @inbox.create_inbox_assignment_policy!(assignment_policy: @assignment_policy)
|
||||
@assignment_policy = @inbox.assignment_policy
|
||||
end
|
||||
|
||||
def destroy
|
||||
remove_inbox_assignment_policy
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_inbox_assignment_policy
|
||||
@inbox.inbox_assignment_policy&.destroy
|
||||
end
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
|
||||
end
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(permitted_params[:assignment_policy_id])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:assignment_policy_id, :inbox_id)
|
||||
end
|
||||
|
||||
def validate_assignment_policy
|
||||
return render_not_found_error(I18n.t('errors.assignment_policy.not_found')) unless @inbox.assignment_policy
|
||||
end
|
||||
end
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue';
|
||||
import { computed, useSlots, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
@@ -24,6 +26,8 @@ const { t } = useI18n();
|
||||
const slots = useSlots();
|
||||
const route = useRoute();
|
||||
|
||||
const isContactSidebarOpen = ref(false);
|
||||
|
||||
const contactId = computed(() => route.params.contactId);
|
||||
|
||||
const selectedContactName = computed(() => {
|
||||
@@ -56,6 +60,15 @@ const handleBreadcrumbClick = () => {
|
||||
const toggleBlock = () => {
|
||||
emit('toggleBlock', isContactBlocked.value);
|
||||
};
|
||||
|
||||
const handleConversationSidebarToggle = () => {
|
||||
isContactSidebarOpen.value = !isContactSidebarOpen.value;
|
||||
};
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
if (!isContactSidebarOpen.value) return;
|
||||
isContactSidebarOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -67,7 +80,9 @@ const toggleBlock = () => {
|
||||
>
|
||||
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||
<div class="w-full mx-auto max-w-[40.625rem]">
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<div
|
||||
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
|
||||
>
|
||||
<Breadcrumb
|
||||
:items="breadcrumbItems"
|
||||
@click="handleBreadcrumbClick"
|
||||
@@ -85,6 +100,11 @@ const toggleBlock = () => {
|
||||
:disabled="isUpdating"
|
||||
@click="toggleBlock"
|
||||
/>
|
||||
<VoiceCallButton
|
||||
:phone="selectedContact?.phoneNumber"
|
||||
:label="$t('CONTACT_PANEL.CALL')"
|
||||
size="sm"
|
||||
/>
|
||||
<ComposeConversation :contact-id="contactId">
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
@@ -105,11 +125,65 @@ const toggleBlock = () => {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Desktop sidebar -->
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile sidebar container -->
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
|
||||
:class="isContactSidebarOpen ? 'w-full' : 'w-16'"
|
||||
>
|
||||
<!-- Toggle button -->
|
||||
<div
|
||||
v-on-click-outside="[
|
||||
closeMobileSidebar,
|
||||
{ ignore: ['#contact-sidebar-content'] },
|
||||
]"
|
||||
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
|
||||
:class="[
|
||||
isContactSidebarOpen
|
||||
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
|
||||
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!rounded-full rtl:rotate-180"
|
||||
:class="{ 'bg-n-alpha-2': isContactSidebarOpen }"
|
||||
:icon="
|
||||
isContactSidebarOpen
|
||||
? 'i-lucide-panel-right-close'
|
||||
: 'i-lucide-panel-right-open'
|
||||
"
|
||||
data-contact-sidebar-toggle
|
||||
@click="handleConversationSidebarToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-200 ease-in-out"
|
||||
leave-active-class="transition-transform duration-200 ease-in-out"
|
||||
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="isContactSidebarOpen"
|
||||
id="contact-sidebar-content"
|
||||
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
+57
-54
@@ -34,13 +34,13 @@ const emit = defineEmits([
|
||||
<template>
|
||||
<header class="sticky top-0 z-10">
|
||||
<div
|
||||
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||
>
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<div v-if="showSearch" class="flex items-center gap-2">
|
||||
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
|
||||
<div v-if="showSearch" class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
:model-value="searchValue"
|
||||
type="search"
|
||||
@@ -48,6 +48,7 @@ const emit = defineEmits([
|
||||
:custom-input-class="[
|
||||
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
||||
]"
|
||||
class="w-full"
|
||||
@input="emit('search', $event.target.value)"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -58,64 +59,66 @@ const emit = defineEmits([
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="!isLabelView && !isActiveView" class="relative">
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="!isLabelView && !isActiveView" class="relative">
|
||||
<Button
|
||||
id="toggleContactsFilterButton"
|
||||
:icon="
|
||||
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
||||
"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="relative w-8"
|
||||
variant="ghost"
|
||||
@click="emit('filter')"
|
||||
>
|
||||
<div
|
||||
v-if="hasActiveFilters && !isSegmentsView"
|
||||
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
||||
/>
|
||||
</Button>
|
||||
<slot name="filter" />
|
||||
</div>
|
||||
<Button
|
||||
id="toggleContactsFilterButton"
|
||||
:icon="
|
||||
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
||||
v-if="
|
||||
hasActiveFilters &&
|
||||
!isSegmentsView &&
|
||||
!isLabelView &&
|
||||
!isActiveView
|
||||
"
|
||||
icon="i-lucide-save"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="relative w-8"
|
||||
variant="ghost"
|
||||
@click="emit('filter')"
|
||||
>
|
||||
<div
|
||||
v-if="hasActiveFilters && !isSegmentsView"
|
||||
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
||||
/>
|
||||
</Button>
|
||||
<slot name="filter" />
|
||||
@click="emit('createSegment')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isSegmentsView && !isLabelView && !isActiveView"
|
||||
icon="i-lucide-trash"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('deleteSegment')"
|
||||
/>
|
||||
<ContactSortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<ContactMoreActions
|
||||
@add="emit('add')"
|
||||
@import="emit('import')"
|
||||
@export="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
v-if="
|
||||
hasActiveFilters &&
|
||||
!isSegmentsView &&
|
||||
!isLabelView &&
|
||||
!isActiveView
|
||||
"
|
||||
icon="i-lucide-save"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('createSegment')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isSegmentsView && !isLabelView && !isActiveView"
|
||||
icon="i-lucide-trash"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('deleteSegment')"
|
||||
/>
|
||||
<ContactSortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<ContactMoreActions
|
||||
@add="emit('add')"
|
||||
@import="emit('import')"
|
||||
@export="emit('export')"
|
||||
/>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
+14
-11
@@ -291,17 +291,20 @@ defineExpose({
|
||||
@delete-segment="openDeleteSegmentDialog"
|
||||
>
|
||||
<template #filter>
|
||||
<ContactsFilter
|
||||
v-if="showFiltersModal"
|
||||
v-model="appliedFilter"
|
||||
:segment-name="activeSegmentName"
|
||||
:is-segment-view="hasActiveSegments"
|
||||
class="absolute mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-segment="onUpdateSegment"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
<div
|
||||
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
|
||||
>
|
||||
<ContactsFilter
|
||||
v-if="showFiltersModal"
|
||||
v-model="appliedFilter"
|
||||
:segment-name="activeSegmentName"
|
||||
:is-segment-view="hasActiveSegments"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-segment="onUpdateSegment"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ContactsHeader>
|
||||
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-on-clickaway="() => (isMenuOpen = false)"
|
||||
class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
|
||||
@@ -96,10 +96,7 @@ const openFilter = () => {
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
<footer
|
||||
v-if="showPaginationFooter"
|
||||
class="sticky bottom-0 z-10 px-4 pb-4"
|
||||
>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<PaginationFooter
|
||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
|
||||
+3
-3
@@ -4,8 +4,8 @@ export default [
|
||||
city: 'Los Angeles',
|
||||
country: 'United States',
|
||||
description:
|
||||
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Chatwoot.",
|
||||
companyName: 'Chatwoot',
|
||||
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Lumora.",
|
||||
companyName: 'Lumora',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'candice-dev',
|
||||
@@ -16,7 +16,7 @@ export default [
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'candice.matherson@chatwoot.com',
|
||||
email: 'candice.matherson@lumora.com',
|
||||
id: 22,
|
||||
name: 'Candice Matherson',
|
||||
phoneNumber: '+14155552671',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { computed, ref, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
phone: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
size: { type: String, default: 'sm' },
|
||||
tooltipLabel: { type: String, default: '' },
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
const attrs = useAttrs();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
)
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const onClick = () => {
|
||||
if (voiceInboxes.value.length > 1) {
|
||||
dialogRef.value?.open();
|
||||
return;
|
||||
}
|
||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||
};
|
||||
|
||||
const onPickInbox = () => {
|
||||
// Placeholder until actual call wiring happens
|
||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="contents">
|
||||
<Button
|
||||
v-if="shouldRender"
|
||||
v-tooltip.top-end="tooltipLabel || null"
|
||||
v-bind="attrs"
|
||||
:label="label"
|
||||
:icon="icon"
|
||||
:size="size"
|
||||
@click="onClick"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
v-if="shouldRender && voiceInboxes.length > 1"
|
||||
ref="dialogRef"
|
||||
:title="$t('CONTACT_PANEL.VOICE_INBOX_PICKER.TITLE')"
|
||||
show-cancel-button
|
||||
:show-confirm-button="false"
|
||||
width="md"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
v-for="inbox in voiceInboxes"
|
||||
:key="inbox.id"
|
||||
type="button"
|
||||
class="flex items-center justify-between w-full px-4 py-2 text-left rounded-lg hover:bg-n-alpha-2"
|
||||
@click="onPickInbox(inbox)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-ri-phone-fill text-n-slate-10" />
|
||||
<span class="text-sm text-n-slate-12">{{ inbox.name }}</span>
|
||||
</div>
|
||||
<span v-if="inbox.phone_number" class="text-xs text-n-slate-10">
|
||||
{{ inbox.phone_number }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</span>
|
||||
</template>
|
||||
+1
@@ -47,6 +47,7 @@ const unreadMessagesCount = computed(() => {
|
||||
</p>
|
||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||
<Avatar
|
||||
v-if="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
|
||||
+1
@@ -96,6 +96,7 @@ defineExpose({
|
||||
/>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
|
||||
@@ -21,9 +21,17 @@ const onClick = (item, index) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
|
||||
<ol class="flex items-center mb-0">
|
||||
<li v-for="(item, index) in items" :key="index" class="flex items-center">
|
||||
<nav
|
||||
:aria-label="t('BREADCRUMB.ARIA_LABEL')"
|
||||
class="flex items-center h-8 min-w-0"
|
||||
>
|
||||
<ol class="flex items-center mb-0 min-w-0">
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
class="flex items-center"
|
||||
:class="{ 'min-w-0 flex-1': index === items.length - 1 }"
|
||||
>
|
||||
<Icon
|
||||
v-if="index > 0"
|
||||
icon="i-lucide-chevron-right"
|
||||
@@ -40,7 +48,7 @@ const onClick = (item, index) => {
|
||||
</button>
|
||||
|
||||
<!-- The last breadcrumb item is plain text -->
|
||||
<span v-else class="text-sm truncate text-n-slate-12 max-w-56">
|
||||
<span v-else class="text-sm truncate text-n-slate-12 min-w-0 block">
|
||||
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -52,10 +52,10 @@ const handleBreadcrumbClick = item => {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="mt-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
||||
class="px-6 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
||||
>
|
||||
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
|
||||
<header class="mb-7 sticky top-0 z-10 bg-n-background">
|
||||
<header class="mb-7 sticky top-0 bg-n-background pt-4 z-20">
|
||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||
</header>
|
||||
<main class="flex gap-16 w-full flex-1 pb-16">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, h, reactive } from 'vue';
|
||||
import { computed, h, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useToggle, useElementSize } from '@vueuse/core';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
@@ -11,6 +11,7 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -31,7 +32,7 @@ const props = defineProps({
|
||||
},
|
||||
tools: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
@@ -60,7 +61,13 @@ const state = reactive({
|
||||
instruction: '',
|
||||
});
|
||||
|
||||
const instructionContentRef = ref();
|
||||
|
||||
const [isEditing, toggleEditing] = useToggle();
|
||||
const [isInstructionExpanded, toggleInstructionExpanded] = useToggle();
|
||||
|
||||
const { height: contentHeight } = useElementSize(instructionContentRef);
|
||||
const needsOverlay = computed(() => contentHeight.value > 160);
|
||||
|
||||
const startEdit = () => {
|
||||
Object.assign(state, {
|
||||
@@ -111,7 +118,7 @@ const LINK_INSTRUCTION_CLASS =
|
||||
|
||||
const renderInstruction = instruction => () =>
|
||||
h('p', {
|
||||
class: `text-sm text-n-slate-12 py-4 mb-0 [&_ol]:list-decimal ${LINK_INSTRUCTION_CLASS}`,
|
||||
class: `text-sm text-n-slate-12 py-4 mb-0 prose prose-sm min-w-0 break-words max-w-none ${LINK_INSTRUCTION_CLASS}`,
|
||||
innerHTML: instruction,
|
||||
});
|
||||
</script>
|
||||
@@ -157,8 +164,38 @@ const renderInstruction = instruction => () =>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<component :is="renderInstruction(formatMessage(instruction, false))" />
|
||||
<span class="text-sm text-n-slate-11 font-medium mb-1">
|
||||
|
||||
<div
|
||||
class="relative overflow-hidden transition-all duration-300 ease-in-out group/expandable"
|
||||
:class="{ 'cursor-pointer': needsOverlay }"
|
||||
:style="{
|
||||
maxHeight: isInstructionExpanded ? `${contentHeight}px` : '10rem',
|
||||
}"
|
||||
@click="needsOverlay ? toggleInstructionExpanded() : null"
|
||||
>
|
||||
<div ref="instructionContentRef">
|
||||
<component
|
||||
:is="renderInstruction(formatMessage(instruction, false))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 w-full flex items-end justify-center text-xs text-n-slate-11 bg-gradient-to-t h-40 from-n-solid-2 via-n-solid-2 via-10% to-transparent transition-all duration-500 ease-in-out px-2 py-1 rounded pointer-events-none"
|
||||
:class="{
|
||||
'visible opacity-100': !isInstructionExpanded,
|
||||
'invisible opacity-0': isInstructionExpanded || !needsOverlay,
|
||||
}"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="text-n-slate-7 mb-4 size-4 group-hover/expandable:text-n-slate-11 transition-colors duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="tools?.length"
|
||||
class="text-sm text-n-slate-11 font-medium mb-1"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
|
||||
{{ tools?.map(tool => `@${tool}`).join(', ') }}
|
||||
</span>
|
||||
|
||||
@@ -192,6 +192,7 @@ defineExpose({ validate });
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-trash"
|
||||
class="flex-shrink-0"
|
||||
@click.stop="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +104,7 @@ const outsideClickHandler = [
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="outsideClickHandler"
|
||||
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
|
||||
class="z-40 max-w-3xl min-w-96 lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
|
||||
>
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ filterModalHeaderTitle }}
|
||||
@@ -146,10 +146,10 @@ const outsideClickHandler = [
|
||||
</template>
|
||||
</ul>
|
||||
<div class="flex justify-between gap-2">
|
||||
<Button sm ghost blue @click="addFilter">
|
||||
<Button sm ghost blue class="flex-shrink-0" @click="addFilter">
|
||||
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.ADD_FILTER') }}
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<Button sm faded slate @click="resetFilter">
|
||||
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.CLEAR_FILTERS') }}
|
||||
</Button>
|
||||
|
||||
@@ -86,6 +86,15 @@ const menuItems = computed(() => {
|
||||
nativeLink: true,
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
showOnCustomBrandedInstance: false,
|
||||
label: t('SIDEBAR_ITEMS.CHANGELOG'),
|
||||
icon: 'i-lucide-scroll-text',
|
||||
link: 'https://www.chatwoot.com/changelog/',
|
||||
nativeLink: true,
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
show: currentUser.value.type === 'SuperAdmin',
|
||||
showOnCustomBrandedInstance: true,
|
||||
@@ -114,7 +123,7 @@ const allowedMenuItems = computed(() => {
|
||||
<DropdownContainer class="relative w-full min-w-0" @close="emit('close')">
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<button
|
||||
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
|
||||
class="flex gap-2 items-center p-1 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
|
||||
:class="{ 'bg-n-alpha-1': isOpen }"
|
||||
@click="toggle"
|
||||
>
|
||||
@@ -127,16 +136,16 @@ const allowedMenuItems = computed(() => {
|
||||
rounded-full
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
|
||||
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
|
||||
{{ currentUser.available_name }}
|
||||
</div>
|
||||
<div class="text-n-slate-11 text-xs truncate">
|
||||
<div class="text-xs truncate text-n-slate-11">
|
||||
{{ currentUser.email }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<DropdownBody class="ltr:left-0 rtl:right-0 bottom-12 z-50 w-80 mb-2">
|
||||
<DropdownBody class="bottom-12 z-50 mb-2 w-80 ltr:left-0 rtl:right-0">
|
||||
<SidebarProfileMenuStatus />
|
||||
<DropdownSeparator />
|
||||
<template v-for="item in allowedMenuItems" :key="item.label">
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"IP_ADDRESS": "IP Address",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "New message",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
|
||||
"TITLE": "Previous Conversations"
|
||||
|
||||
@@ -226,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"TEXT": "Texto",
|
||||
"NUMBER": "Número",
|
||||
"LINK": "Link",
|
||||
"DATE": "Date",
|
||||
"DATE": "Data",
|
||||
"LIST": "Lista",
|
||||
"CHECKBOX": "Checkbox"
|
||||
},
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"CONVERSATION_CREATED": "Conversa Criada",
|
||||
"CONVERSATION_UPDATED": "Conversa Atualizada",
|
||||
"MESSAGE_CREATED": "Mensagem Criada",
|
||||
"CONVERSATION_RESOLVED": "Conversation Resolved",
|
||||
"CONVERSATION_RESOLVED": "Conversa resolvida",
|
||||
"CONVERSATION_OPENED": "Conversa Aberta"
|
||||
},
|
||||
"ACTIONS": {
|
||||
@@ -153,8 +153,8 @@
|
||||
"OPEN_CONVERSATION": "Abrir conversa"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"INCOMING": "Incoming Message",
|
||||
"OUTGOING": "Outgoing Message"
|
||||
"INCOMING": "Mensagem Recebida",
|
||||
"OUTGOING": "Mensagem de Saída"
|
||||
},
|
||||
"PRIORITY_TYPES": {
|
||||
"NONE": "Nenhuma",
|
||||
|
||||
@@ -138,11 +138,11 @@
|
||||
}
|
||||
},
|
||||
"WHATSAPP": {
|
||||
"HEADER_TITLE": "WhatsApp campaigns",
|
||||
"HEADER_TITLE": "Campanhas do WhatsApp",
|
||||
"NEW_CAMPAIGN": "Criar campanha",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No WhatsApp campaigns are available",
|
||||
"SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
|
||||
"TITLE": "Nenhuma campanha do WhatsApp está disponível",
|
||||
"SUBTITLE": "Inicie uma campanha do WhatsApp para atingir seus clientes diretamente. Envie ofertas ou faça anúncios facilmente. Clique em \"Criar campanha\" para começar."
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
@@ -155,7 +155,7 @@
|
||||
}
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Create WhatsApp campaign",
|
||||
"TITLE": "Criar campanha do WhatsApp",
|
||||
"CANCEL_BUTTON_TEXT": "Cancelar",
|
||||
"CREATE_BUTTON_TEXT": "Criar",
|
||||
"FORM": {
|
||||
@@ -170,15 +170,15 @@
|
||||
"ERROR": "Caixa de entrada obrigatória"
|
||||
},
|
||||
"TEMPLATE": {
|
||||
"LABEL": "WhatsApp Template",
|
||||
"PLACEHOLDER": "Select a template",
|
||||
"INFO": "Select a template to use for this campaign.",
|
||||
"ERROR": "Template is required",
|
||||
"LABEL": "Modelo do WhatsApp",
|
||||
"PLACEHOLDER": "Selecione um modelo",
|
||||
"INFO": "Selecione um modelo para usar para esta campanha.",
|
||||
"ERROR": "Modelo é obrigatório",
|
||||
"PREVIEW_TITLE": "Processar {templateName}",
|
||||
"LANGUAGE": "Idioma",
|
||||
"CATEGORY": "Categoria",
|
||||
"VARIABLES_LABEL": "Variáveis",
|
||||
"VARIABLE_PLACEHOLDER": "Enter value for {variable}"
|
||||
"VARIABLE_PLACEHOLDER": "Digite um valor para {variable}"
|
||||
},
|
||||
"AUDIENCE": {
|
||||
"LABEL": "Público",
|
||||
@@ -195,7 +195,7 @@
|
||||
"CANCEL": "Cancelar"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp campaign created successfully",
|
||||
"SUCCESS_MESSAGE": "Campanha do WhatsApp criada com sucesso",
|
||||
"ERROR_MESSAGE": "Houve um erro. Por favor, tente novamente."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,6 @@
|
||||
"PLACEHOLDER": "Insira a duração"
|
||||
},
|
||||
"CHANNEL_SELECTOR": {
|
||||
"COMING_SOON": "Coming Soon!"
|
||||
"COMING_SOON": "Em breve!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,9 +144,9 @@
|
||||
"AGENTS_LOADING": "Carregando agentes...",
|
||||
"ASSIGN_TEAM": "Atribuir time",
|
||||
"DELETE": "Excluir conversa",
|
||||
"OPEN_IN_NEW_TAB": "Open in new tab",
|
||||
"COPY_LINK": "Copy conversation link",
|
||||
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
|
||||
"OPEN_IN_NEW_TAB": "Abrir em nova aba",
|
||||
"COPY_LINK": "Copiar link da conversa",
|
||||
"COPY_LINK_SUCCESS": "Link da conversa copiado",
|
||||
"API": {
|
||||
"AGENT_ASSIGNMENT": {
|
||||
"SUCCESFUL": "ID da conversa {conversationId} atribuído para \"{agentName}\"",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"LIMIT_MESSAGES": {
|
||||
"CONVERSATION": "Você excedeu o limite de conversas. O plano Hacker permite apenas 500 conversas.",
|
||||
"INBOXES": "Você excedeu o limite da caixa de entrada. O plano Hacker só suporta chat ao vivo do site. Caixas adicionais como e-mail, WhatsApp etc. requerem um plano pago.",
|
||||
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
|
||||
"AGENTS": "Você excedeu o limite do agente. Seu plano permite apenas {allowedAgents} agentes.",
|
||||
"NON_ADMIN": "Entre em contato com o administrador para atualizar o plano e continuar usando todos os recursos."
|
||||
},
|
||||
"TITLE": "Conta",
|
||||
@@ -134,7 +134,7 @@
|
||||
"MULTISELECT": {
|
||||
"ENTER_TO_SELECT": "Digite enter para selecionar",
|
||||
"ENTER_TO_REMOVE": "Digite enter para remover",
|
||||
"NO_OPTIONS": "List is empty",
|
||||
"NO_OPTIONS": "Lista vazia",
|
||||
"SELECT_ONE": "Selecione um",
|
||||
"SELECT": "Selecionar"
|
||||
}
|
||||
|
||||
@@ -160,8 +160,8 @@
|
||||
},
|
||||
"SEND_CNAME_INSTRUCTIONS": {
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CNAME instructions sent successfully",
|
||||
"ERROR_MESSAGE": "Error while sending CNAME instructions"
|
||||
"SUCCESS_MESSAGE": "Instruções do CNAME enviadas com sucesso",
|
||||
"ERROR_MESSAGE": "Erro ao enviar as instruções CNAME"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -732,7 +732,7 @@
|
||||
"HOME_PAGE_LINK": {
|
||||
"LABEL": "Link da Página Inicial",
|
||||
"PLACEHOLDER": "Link da página inicial do portal",
|
||||
"ERROR": "Enter a valid URL. The Home page link must start with 'http://' or 'https://'."
|
||||
"ERROR": "Digite uma URL válida. O link da página inicial deve começar com 'http://' ou 'https://'."
|
||||
},
|
||||
"SLUG": {
|
||||
"LABEL": "Slug",
|
||||
@@ -753,14 +753,14 @@
|
||||
"HEADER": "Domínio personalizado",
|
||||
"LABEL": "Domínio personalizado:",
|
||||
"DESCRIPTION": "Você pode hospedar seu portal em um domínio personalizado. Por exemplo, se seu site for meudominio.com e você quer o seu portal disponível em docs.meudominio.com, basta digitar isso neste campo.",
|
||||
"STATUS_DESCRIPTION": "Your custom portal will start working as soon as it is verified.",
|
||||
"STATUS_DESCRIPTION": "Seu portal personalizado começará a funcionar assim que for verificado.",
|
||||
"PLACEHOLDER": "Domínio personalizado do portal",
|
||||
"EDIT_BUTTON": "Alterar",
|
||||
"ADD_BUTTON": "Adicionar domínio personalizado",
|
||||
"STATUS": {
|
||||
"LIVE": "Em tempo real",
|
||||
"PENDING": "Awaiting verification",
|
||||
"ERROR": "Verification failed"
|
||||
"PENDING": "Aguardando verificação",
|
||||
"ERROR": "Verificação falhou"
|
||||
},
|
||||
"DIALOG": {
|
||||
"ADD_HEADER": "Adicionar domínio personalizado",
|
||||
@@ -770,17 +770,17 @@
|
||||
"LABEL": "Domínio personalizado",
|
||||
"PLACEHOLDER": "Domínio personalizado do portal",
|
||||
"ERROR": "Domínio personalizado é obrigatório",
|
||||
"FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com"
|
||||
"FORMAT_ERROR": "Por favor, insira um domínio de URL válido, ex.: docs.seudominio.com"
|
||||
},
|
||||
"DNS_CONFIGURATION_DIALOG": {
|
||||
"HEADER": "Configuração de DNS",
|
||||
"DESCRIPTION": "Faça o login na conta que você tem com seu provedor DNS e adicione um registro CNAME para subdomínio apontando para chatwoot.help",
|
||||
"COPY": "Successfully copied CNAME",
|
||||
"COPY": "CNAME copiado com sucesso",
|
||||
"SEND_INSTRUCTIONS": {
|
||||
"HEADER": "Send instructions",
|
||||
"DESCRIPTION": "If you would prefer to have someone from your development team to handle this step, you can enter email address below, and we will send them the required instructions.",
|
||||
"PLACEHOLDER": "Enter their email",
|
||||
"ERROR": "Enter a valid email address",
|
||||
"HEADER": "Enviar instruções",
|
||||
"DESCRIPTION": "Se você preferir ter alguém da sua equipe de desenvolvimento para lidar com essa etapa, você pode digitar o endereço de e-mail abaixo e nós enviaremos as instruções necessárias.",
|
||||
"PLACEHOLDER": "Insira o e-mail dele",
|
||||
"ERROR": "Insira um endereço de e-mail válido",
|
||||
"SEND_BUTTON": "Enviar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,21 +74,21 @@
|
||||
"DELETE_ALL_READ": "Todas as notificações lidas foram excluídas"
|
||||
},
|
||||
"REAUTHORIZE": {
|
||||
"TITLE": "Reauthorization Required",
|
||||
"DESCRIPTION": "Your WhatsApp connection has expired. Please reconnect to continue receiving and sending messages.",
|
||||
"BUTTON_TEXT": "Reconnect WhatsApp",
|
||||
"LOADING_FACEBOOK": "Loading Facebook SDK...",
|
||||
"SUCCESS": "WhatsApp reconnected successfully",
|
||||
"ERROR": "Failed to reconnect WhatsApp. Please try again.",
|
||||
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
|
||||
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
|
||||
"CONFIGURATION_ERROR": "Configuration error occurred during reauthorization.",
|
||||
"FACEBOOK_LOAD_ERROR": "Failed to load Facebook SDK. Please try again.",
|
||||
"TITLE": "Reautenticação necessária",
|
||||
"DESCRIPTION": "Sua conexão com o WhatsApp expirou. Por favor, reconecte para continuar recebendo e enviando mensagens.",
|
||||
"BUTTON_TEXT": "Reconectar WhatsApp",
|
||||
"LOADING_FACEBOOK": "Carregando SDK do Facebook...",
|
||||
"SUCCESS": "WhatsApp reconectado com sucesso",
|
||||
"ERROR": "Falha ao reconectar o WhatsApp. Por favor, tente novamente.",
|
||||
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID não está configurado. Por favor, contate seu administrador.",
|
||||
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID não está configurado. Por favor, contate seu administrador.",
|
||||
"CONFIGURATION_ERROR": "Ocorreu um erro de configuração ao reautenticar.",
|
||||
"FACEBOOK_LOAD_ERROR": "Falha para carregar o SDK do Facebook. Por favor, tente novamente.",
|
||||
"TROUBLESHOOTING": {
|
||||
"TITLE": "Troubleshooting",
|
||||
"POPUP_BLOCKED": "Ensure pop-ups are allowed for this site",
|
||||
"COOKIES": "Third-party cookies must be enabled",
|
||||
"ADMIN_ACCESS": "You need admin access to the WhatsApp Business Account"
|
||||
"TITLE": "Solucionar problemas",
|
||||
"POPUP_BLOCKED": "Certifique-se de que os pop-ups são permitidos para este site",
|
||||
"COOKIES": "_Cookies_ de terceiros devem estar habilitados",
|
||||
"ADMIN_ACCESS": "Você precisa de acesso de administrador na conta do WhatsApp Business"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,13 +225,13 @@
|
||||
"WHATSAPP_EMBEDDED": "WhatsApp Business",
|
||||
"TWILIO": "Twilio",
|
||||
"WHATSAPP_CLOUD": "Cloud do WhatsApp",
|
||||
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
|
||||
"TWILIO_DESC": "Connect via Twilio credentials",
|
||||
"WHATSAPP_CLOUD_DESC": "Configuração rápida via Meta",
|
||||
"TWILIO_DESC": "Conectar através de credenciais Twilio",
|
||||
"360_DIALOG": "360Dialog"
|
||||
},
|
||||
"SELECT_PROVIDER": {
|
||||
"TITLE": "Select your API provider",
|
||||
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
|
||||
"TITLE": "Selecione seu provedor de API",
|
||||
"DESCRIPTION": "Escolha seu provedor do WhatsApp. Você pode se conectar diretamente através de metade, que não requer nenhuma configuração ou se conectar pelo Twilio usando as credenciais da sua conta."
|
||||
},
|
||||
"INBOX_NAME": {
|
||||
"LABEL": "Nome da Caixa de Entrada",
|
||||
@@ -272,74 +272,74 @@
|
||||
},
|
||||
"SUBMIT_BUTTON": "Criar canal do WhatsApp",
|
||||
"EMBEDDED_SIGNUP": {
|
||||
"TITLE": "Quick Setup with Meta",
|
||||
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
|
||||
"TITLE": "Configuração rápida com Meta",
|
||||
"DESC": "Você será redirecionado para a Meta para entrar na sua conta do WhatsApp Business. Ter acesso administrativo ajudará a facilitar a instalação.",
|
||||
"BENEFITS": {
|
||||
"TITLE": "Benefits of Embedded Signup:",
|
||||
"EASY_SETUP": "No manual configuration required",
|
||||
"SECURE_AUTH": "Secure OAuth based authentication",
|
||||
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
|
||||
"TITLE": "Benefícios da inscrição incorporada:",
|
||||
"EASY_SETUP": "Nenhuma configuração manual é necessária",
|
||||
"SECURE_AUTH": "Autenticação segura baseada em OAuth",
|
||||
"AUTO_CONFIG": "Configuração automática de webhook e número de telefone"
|
||||
},
|
||||
"LEARN_MORE": {
|
||||
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
|
||||
"LINK_TEXT": "this link.",
|
||||
"TEXT": "Para saber mais sobre inscrições integradas, preços e limitações visite",
|
||||
"LINK_TEXT": "este link.",
|
||||
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
|
||||
"AUTH_PROCESSING": "Authenticating with Meta",
|
||||
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
||||
"PROCESSING": "Setting up your WhatsApp Business Account",
|
||||
"LOADING_SDK": "Loading Facebook SDK...",
|
||||
"CANCELLED": "WhatsApp Signup was cancelled",
|
||||
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
|
||||
"WAITING_FOR_AUTH": "Waiting for authentication...",
|
||||
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
|
||||
"SIGNUP_ERROR": "Signup error occurred",
|
||||
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
|
||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
|
||||
"SUBMIT_BUTTON": "Conecte-se com WhatsApp Business",
|
||||
"AUTH_PROCESSING": "Autenticando com Meta",
|
||||
"WAITING_FOR_BUSINESS_INFO": "Por favor, complete a configuração do negócio na janela da Meta...",
|
||||
"PROCESSING": "Configurando sua conta do WhatsApp Business",
|
||||
"LOADING_SDK": "Carregando SDK do Facebook...",
|
||||
"CANCELLED": "A inscrição no WhatsApp foi cancelada",
|
||||
"SUCCESS_TITLE": "Conta do WhatsApp Business conectada!",
|
||||
"WAITING_FOR_AUTH": "Aguardando autenticação...",
|
||||
"INVALID_BUSINESS_DATA": "Dados de negócio inválidos recebidos do Facebook. Por favor, tente novamente.",
|
||||
"SIGNUP_ERROR": "Ocorreu um erro no cadastro",
|
||||
"AUTH_NOT_COMPLETED": "Autenticação não concluída. Por favor, reinicie o processo.",
|
||||
"SUCCESS_FALLBACK": "A conta do WhatsApp Business foi configurada com sucesso"
|
||||
},
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "Não foi possível salvar o canal do WhatsApp"
|
||||
}
|
||||
},
|
||||
"VOICE": {
|
||||
"TITLE": "Voice Channel",
|
||||
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
|
||||
"TITLE": "Canal de Voz",
|
||||
"DESC": "Integre o Twilio Voice e comece a oferecer suporte a seus clientes através de chamadas telefônicas.",
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Número de Telefone",
|
||||
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
|
||||
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
|
||||
"PLACEHOLDER": "Digite seu número de telefone (por exemplo, +551234567890)",
|
||||
"ERROR": "Por favor, forneça um número de telefone válido no formato E.164 (por exemplo, +551234567890)"
|
||||
},
|
||||
"TWILIO": {
|
||||
"ACCOUNT_SID": {
|
||||
"LABEL": "SID da Conta",
|
||||
"PLACEHOLDER": "Enter your Twilio Account SID",
|
||||
"REQUIRED": "Account SID is required"
|
||||
"PLACEHOLDER": "Insira o SID da sua Conta Twilio",
|
||||
"REQUIRED": "O SID da conta é necessário"
|
||||
},
|
||||
"AUTH_TOKEN": {
|
||||
"LABEL": "Token de autenticação",
|
||||
"PLACEHOLDER": "Enter your Twilio Auth Token",
|
||||
"REQUIRED": "Auth Token is required"
|
||||
"PLACEHOLDER": "Por favor, digite seu Token de Autenticação do Twilio",
|
||||
"REQUIRED": "Um Token de autenticação é necessário"
|
||||
},
|
||||
"API_KEY_SID": {
|
||||
"LABEL": "Chave da API SID",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key SID",
|
||||
"REQUIRED": "API Key SID is required"
|
||||
"PLACEHOLDER": "Insira sua chave de API do Twilio SID",
|
||||
"REQUIRED": "API Key SID é obrigatório"
|
||||
},
|
||||
"API_KEY_SECRET": {
|
||||
"LABEL": "Segredo da Chave API",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
"PLACEHOLDER": "Digite o segredo da sua chave de API do Twilio",
|
||||
"REQUIRED": "Segredo da chave da API é obrigatório"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
"PLACEHOLDER": "Insira seu Twilio TwiML App SID (começa com AP)",
|
||||
"REQUIRED": "TwiML App SID é obrigatório"
|
||||
}
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"SUBMIT_BUTTON": "Criar Canal de Voz",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
"ERROR_MESSAGE": "Não conseguimos criar o canal de voz"
|
||||
}
|
||||
},
|
||||
"API_CHANNEL": {
|
||||
@@ -603,27 +603,27 @@
|
||||
"WHATSAPP_SECTION_UPDATE_TITLE": "Atualizar Chave de API",
|
||||
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Digite a nova chave de API aqui",
|
||||
"WHATSAPP_SECTION_UPDATE_BUTTON": "Atualizar",
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "WhatsApp Embedded Signup",
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
|
||||
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
|
||||
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
|
||||
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
|
||||
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "Inscrição incorporada do WhatsApp",
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "Esta caixa de entrada está conectada através da inscrição incorporada do WhatsApp.",
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "Você pode reconfigurar esta caixa de entrada para atualizar suas configurações do WhatsApp Business.",
|
||||
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigurar",
|
||||
"WHATSAPP_CONNECT_TITLE": "Conectar ao WhatsApp Business",
|
||||
"WHATSAPP_CONNECT_SUBHEADER": ".",
|
||||
"WHATSAPP_CONNECT_DESCRIPTION": "Conecte esta caixa de entrada ao WhatsApp Business para ter recursos aprimorados e um gerenciamento mais fácil.",
|
||||
"WHATSAPP_CONNECT_BUTTON": "Conectar",
|
||||
"WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!",
|
||||
"WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.",
|
||||
"WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!",
|
||||
"WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.",
|
||||
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
|
||||
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
|
||||
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.",
|
||||
"WHATSAPP_CONNECT_SUCCESS": "Conectado com sucesso ao WhatsApp Business!",
|
||||
"WHATSAPP_CONNECT_ERROR": "Não foi possível reconfigurar o WhatsApp Business. Tente novamente.",
|
||||
"WHATSAPP_RECONFIGURE_SUCCESS": "WhatsApp Business reconfigurado com sucesso!",
|
||||
"WHATSAPP_RECONFIGURE_ERROR": "Não foi possível reconfigurar o WhatsApp Business. Tente novamente.",
|
||||
"WHATSAPP_APP_ID_MISSING": "O ID do WhatsApp não está configurado. Por favor, contate o administrador.",
|
||||
"WHATSAPP_CONFIG_ID_MISSING": "O ID de Configuração do WhatsApp não está configurado. Por favor, contate o administrador.",
|
||||
"WHATSAPP_LOGIN_CANCELLED": "O login do WhatsApp foi cancelado. Por favor, tente novamente.",
|
||||
"WHATSAPP_WEBHOOK_TITLE": "Token de verificação Webhook",
|
||||
"WHATSAPP_WEBHOOK_SUBHEADER": "Este token é usado para verificar a autenticidade do webhook endpoint.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sincronizar Modelos",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Sincronize manualmente os modelos de mensagens do WhatsApp para atualizar seus modelos disponíveis.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sincronizar Modelos",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Sincronização de modelos iniciada com sucesso. Pode demorar alguns minutos para atualizar.",
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Atualizar configurações do Formulário Pre Chat"
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
@@ -883,7 +883,7 @@
|
||||
"LINE": "Line",
|
||||
"API": "Canal da API",
|
||||
"INSTAGRAM": "Instagram",
|
||||
"VOICE": "Voice"
|
||||
"VOICE": "Voz"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,8 +334,8 @@
|
||||
},
|
||||
"NOTION": {
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the Notion integration?",
|
||||
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
|
||||
"TITLE": "Você tem certeza que deseja excluir a integração com Notion?",
|
||||
"MESSAGE": "Excluir essa integração removerá o acesso ao seu espaço de trabalho Notion e encerrará todas as funcionalidades relacionadas.",
|
||||
"CONFIRM": "Sim, excluir",
|
||||
"CANCEL": "Cancelar"
|
||||
}
|
||||
@@ -473,7 +473,7 @@
|
||||
"TITLE": "Funcionalidades",
|
||||
"ALLOW_CONVERSATION_FAQS": "Gerar perguntas frequentes a partir de conversas resolvidas",
|
||||
"ALLOW_MEMORIES": "Capture os principais detalhes como memórias de interações do cliente.",
|
||||
"ALLOW_CITATIONS": "Include source citations in responses"
|
||||
"ALLOW_CITATIONS": "Incluir fonte de citações nas respostas"
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
@@ -487,28 +487,28 @@
|
||||
"ASSISTANT": "Assistente"
|
||||
},
|
||||
"BASIC_SETTINGS": {
|
||||
"TITLE": "Basic settings",
|
||||
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
||||
"TITLE": "Configurações básicas",
|
||||
"DESCRIPTION": "Personalize o que o assistente diz quando termina uma conversa ou transfere para um humano."
|
||||
},
|
||||
"SYSTEM_SETTINGS": {
|
||||
"TITLE": "System settings",
|
||||
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
||||
"TITLE": "Configurações do sistema",
|
||||
"DESCRIPTION": "Personalize o que o assistente diz quando termina uma conversa ou transfere para um humano."
|
||||
},
|
||||
"CONTROL_ITEMS": {
|
||||
"TITLE": "The Fun Stuff",
|
||||
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
|
||||
"TITLE": "As Coisas Divertidas",
|
||||
"DESCRIPTION": "Adicione mais controle ao assistente. (algo mais visual como uma história: Consulta → cenários → saída) Força o usuário para realmente utilizá-los.",
|
||||
"OPTIONS": {
|
||||
"GUARDRAILS": {
|
||||
"TITLE": "Guardrails",
|
||||
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
|
||||
"TITLE": "Proteções",
|
||||
"DESCRIPTION": "Mantém as coisas no caminho — apenas os tipos de perguntas que você quer que seu assistente responda, nada fora de limites ou fora do tópico."
|
||||
},
|
||||
"SCENARIOS": {
|
||||
"TITLE": "Scenarios",
|
||||
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
|
||||
"TITLE": "Cenários",
|
||||
"DESCRIPTION": "Dê algum contexto ao seu assistente — como \"o que fazer quando um usuário estiver com problemas\", ou \"como agir durante uma solicitação de reembolso\"."
|
||||
},
|
||||
"RESPONSE_GUIDELINES": {
|
||||
"TITLE": "Response guidelines",
|
||||
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
|
||||
"TITLE": "Diretrizes de resposta",
|
||||
"DESCRIPTION": "O jeito e a estrutura das respostas do seu assistente — tranquilo e amigável? Curto e ágil? Detalhado e formal?"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,138 +527,138 @@
|
||||
}
|
||||
},
|
||||
"GUARDRAILS": {
|
||||
"TITLE": "Guardrails",
|
||||
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic.",
|
||||
"TITLE": "Proteções",
|
||||
"DESCRIPTION": "Mantém as coisas no caminho — apenas os tipos de perguntas que você quer que seu assistente responda, nada fora de limites ou fora do tópico.",
|
||||
"BREADCRUMB": {
|
||||
"TITLE": "Guardrails"
|
||||
"TITLE": "Proteções"
|
||||
},
|
||||
"BULK_ACTION": {
|
||||
"SELECTED": "{count} item selected | {count} items selected",
|
||||
"SELECTED": "{count} item selecionado | {count} itens selecionados",
|
||||
"SELECT_ALL": "Selecionar todos ({count})",
|
||||
"UNSELECT_ALL": "Desmarcar todos ({count})",
|
||||
"BULK_DELETE_BUTTON": "Excluir"
|
||||
},
|
||||
"ADD": {
|
||||
"SUGGESTED": {
|
||||
"TITLE": "Example guardrails",
|
||||
"ADD": "Add all",
|
||||
"ADD_SINGLE": "Add this",
|
||||
"SAVE": "Add and save (↵)",
|
||||
"PLACEHOLDER": "Type in another guardrail..."
|
||||
"TITLE": "Exemplos de proteções",
|
||||
"ADD": "Adicionar todos",
|
||||
"ADD_SINGLE": "Adicionar este",
|
||||
"SAVE": "Adicionar e salvar (↵)",
|
||||
"PLACEHOLDER": "Escreva outra proteção"
|
||||
},
|
||||
"NEW": {
|
||||
"TITLE": "Add a guardrail",
|
||||
"TITLE": "Adicionar proteção",
|
||||
"CREATE": "Criar",
|
||||
"CANCEL": "Cancelar",
|
||||
"PLACEHOLDER": "Type in another guardrail...",
|
||||
"TEST_ALL": "Test all"
|
||||
"PLACEHOLDER": "Escreva outra proteção",
|
||||
"TEST_ALL": "Testar tudo"
|
||||
}
|
||||
},
|
||||
"LIST": {
|
||||
"SEARCH_PLACEHOLDER": "Pesquisar..."
|
||||
},
|
||||
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
|
||||
"SEARCH_EMPTY_MESSAGE": "No guardrails found for this search.",
|
||||
"EMPTY_MESSAGE": "Nenhuma proteção encontrada. Crie uma ou adicione exemplos para começar.",
|
||||
"SEARCH_EMPTY_MESSAGE": "Nenhuma proteção encontrada para essa pesquisa.",
|
||||
"API": {
|
||||
"ADD": {
|
||||
"SUCCESS": "Guardrails added successfully",
|
||||
"ERROR": "There was an error adding guardrails, please try again."
|
||||
"SUCCESS": "Proteções adicionadas com sucesso",
|
||||
"ERROR": "Ocorreu um erro ao adicionar as proteções. Por favor, tente novamente."
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS": "Guardrails updated successfully",
|
||||
"ERROR": "There was an error updating guardrails, please try again."
|
||||
"SUCCESS": "Proteções atualizados com sucesso",
|
||||
"ERROR": "Ocorreu um erro ao atualizar as proteções. Por favor, tente novamente."
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS": "Guardrails deleted successfully",
|
||||
"ERROR": "There was an error deleting guardrails, please try again."
|
||||
"SUCCESS": "Proteções removidas com sucesso",
|
||||
"ERROR": "Ocorreu um erro ao excluir as proteções, por favor, tente novamente."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RESPONSE_GUIDELINES": {
|
||||
"TITLE": "Response Guidelines",
|
||||
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?",
|
||||
"TITLE": "Diretrizes de Resposta",
|
||||
"DESCRIPTION": "O jeito e a estrutura das respostas do seu assistente — tranquilo e amigável? Curto e ágil? Detalhado e formal?",
|
||||
"BREADCRUMB": {
|
||||
"TITLE": "Response Guidelines"
|
||||
"TITLE": "Diretrizes de Resposta"
|
||||
},
|
||||
"BULK_ACTION": {
|
||||
"SELECTED": "{count} item selected | {count} items selected",
|
||||
"SELECTED": "{count} item selecionado | {count} itens selecionados",
|
||||
"SELECT_ALL": "Selecionar todos ({count})",
|
||||
"UNSELECT_ALL": "Desmarcar todos ({count})",
|
||||
"BULK_DELETE_BUTTON": "Excluir"
|
||||
},
|
||||
"ADD": {
|
||||
"SUGGESTED": {
|
||||
"TITLE": "Example response guidelines",
|
||||
"ADD": "Add all",
|
||||
"ADD_SINGLE": "Add this",
|
||||
"SAVE": "Add and save (↵)",
|
||||
"PLACEHOLDER": "Type in another response guideline..."
|
||||
"TITLE": "Exemplos de diretrizes de resposta",
|
||||
"ADD": "Adicionar todos",
|
||||
"ADD_SINGLE": "Adicionar este",
|
||||
"SAVE": "Adicionar e salvar (↵)",
|
||||
"PLACEHOLDER": "Escreva uma outra diretriz de resposta..."
|
||||
},
|
||||
"NEW": {
|
||||
"TITLE": "Add a response guideline",
|
||||
"TITLE": "Adicione uma diretriz de resposta",
|
||||
"CREATE": "Criar",
|
||||
"CANCEL": "Cancelar",
|
||||
"PLACEHOLDER": "Type in another response guideline...",
|
||||
"TEST_ALL": "Test all"
|
||||
"PLACEHOLDER": "Escreva uma outra diretriz de resposta...",
|
||||
"TEST_ALL": "Testar tudo"
|
||||
}
|
||||
},
|
||||
"LIST": {
|
||||
"SEARCH_PLACEHOLDER": "Pesquisar..."
|
||||
},
|
||||
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
|
||||
"SEARCH_EMPTY_MESSAGE": "No response guidelines found for this search.",
|
||||
"EMPTY_MESSAGE": "Nenhuma diretriz de resposta encontrada. Crie uma ou adicione exemplos para começar.",
|
||||
"SEARCH_EMPTY_MESSAGE": "Nenhuma diretriz de resposta encotrada para essa pesquisa.",
|
||||
"API": {
|
||||
"ADD": {
|
||||
"SUCCESS": "Response Guidelines added successfully",
|
||||
"ERROR": "There was an error adding response guidelines, please try again."
|
||||
"SUCCESS": "Diretrizes de resposta adicionadas com sucesso",
|
||||
"ERROR": "Houve um erro ao adicionar diretrizes de resposta, por favor, tente novamente."
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS": "Response Guidelines updated successfully",
|
||||
"ERROR": "There was an error updating response guidelines, please try again."
|
||||
"SUCCESS": "Diretrizes de Resposta atualizadas com sucesso",
|
||||
"ERROR": "Houve um erro ao atualizar as diretrizes de resposta, por favor, tente novamente."
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS": "Response Guidelines deleted successfully",
|
||||
"ERROR": "There was an error deleting response guidelines, please try again."
|
||||
"SUCCESS": "Diretrizes de resposta removidas com sucesso",
|
||||
"ERROR": "Houve um erro ao excluir as diretrizes de resposta, por favor, tente novamente."
|
||||
}
|
||||
}
|
||||
},
|
||||
"SCENARIOS": {
|
||||
"TITLE": "Scenarios",
|
||||
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
|
||||
"TITLE": "Cenários",
|
||||
"DESCRIPTION": "Dê algum contexto ao seu assistente — como \"o que fazer quando um usuário estiver com problemas\", ou \"como agir durante uma solicitação de reembolso\".",
|
||||
"BREADCRUMB": {
|
||||
"TITLE": "Scenarios"
|
||||
"TITLE": "Cenários"
|
||||
},
|
||||
"BULK_ACTION": {
|
||||
"SELECTED": "{count} item selected | {count} items selected",
|
||||
"SELECTED": "{count} item selecionado | {count} itens selecionados",
|
||||
"SELECT_ALL": "Selecionar todos ({count})",
|
||||
"UNSELECT_ALL": "Desmarcar todos ({count})",
|
||||
"BULK_DELETE_BUTTON": "Excluir"
|
||||
},
|
||||
"ADD": {
|
||||
"SUGGESTED": {
|
||||
"TITLE": "Example scenarios",
|
||||
"ADD": "Add all",
|
||||
"ADD_SINGLE": "Add this",
|
||||
"TOOLS_USED": "Tools used :"
|
||||
"TITLE": "Exemplos de cenários",
|
||||
"ADD": "Adicionar todos",
|
||||
"ADD_SINGLE": "Adicionar este",
|
||||
"TOOLS_USED": "Ferramentas usadas :"
|
||||
},
|
||||
"NEW": {
|
||||
"CREATE": "Add a scenario",
|
||||
"TITLE": "Create a scenario",
|
||||
"CREATE": "Adicionar um cenário",
|
||||
"TITLE": "Criar um cenário",
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Título",
|
||||
"PLACEHOLDER": "Enter a name for the scenario",
|
||||
"ERROR": "Scenario name is required"
|
||||
"PLACEHOLDER": "Digite um nome para o cenário",
|
||||
"ERROR": "O nome do cenário é obrigatório"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Descrição",
|
||||
"PLACEHOLDER": "Describe how and where this scenario will be used",
|
||||
"ERROR": "Scenario description is required"
|
||||
"PLACEHOLDER": "Descreva como e onde este cenário será utilizado",
|
||||
"ERROR": "Descrição do cenário é obrigatória"
|
||||
},
|
||||
"INSTRUCTION": {
|
||||
"LABEL": "How to handle",
|
||||
"PLACEHOLDER": "Describe how and where this scenario will be handled",
|
||||
"ERROR": "Scenario content is required"
|
||||
"LABEL": "Como lidar",
|
||||
"PLACEHOLDER": "Descreva como e onde este cenário será utilizado",
|
||||
"ERROR": "Conteúdo do cenário é obrigatório"
|
||||
},
|
||||
"CREATE": "Criar",
|
||||
"CANCEL": "Cancelar"
|
||||
@@ -667,25 +667,25 @@
|
||||
},
|
||||
"UPDATE": {
|
||||
"CANCEL": "Cancelar",
|
||||
"UPDATE": "Update changes"
|
||||
"UPDATE": "Atualizar alterações"
|
||||
},
|
||||
"LIST": {
|
||||
"SEARCH_PLACEHOLDER": "Pesquisar..."
|
||||
},
|
||||
"EMPTY_MESSAGE": "No scenarios found. Create or add examples to begin.",
|
||||
"SEARCH_EMPTY_MESSAGE": "No scenarios found for this search.",
|
||||
"EMPTY_MESSAGE": "Nenhum cenário encontrado. Crie ou adicione exemplos para começar.",
|
||||
"SEARCH_EMPTY_MESSAGE": "Nenhum cenário encontrado para esta pesquisa.",
|
||||
"API": {
|
||||
"ADD": {
|
||||
"SUCCESS": "Scenarios added successfully",
|
||||
"ERROR": "There was an error adding scenarios, please try again."
|
||||
"SUCCESS": "Cenários adicionados com sucesso",
|
||||
"ERROR": "Ocorreu um erro ao adicionar cenários, por favor tente novamente."
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS": "Scenarios updated successfully",
|
||||
"ERROR": "There was an error updating scenarios, please try again."
|
||||
"SUCCESS": "Cenários atualizados com sucesso",
|
||||
"ERROR": "Ocorreu um erro ao atualizar cenários, por favor tente novamente."
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS": "Scenarios deleted successfully",
|
||||
"ERROR": "There was an error deleting scenarios, please try again."
|
||||
"SUCCESS": "Cenários excluídos com sucesso",
|
||||
"ERROR": "Ocorreu um erro ao excluir os cenários, por favor tente novamente."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
"MODAL": {
|
||||
"TITLE": "Templates do Whatsapp",
|
||||
"SUBTITLE": "Selecione o template do whatsapp que você deseja enviar",
|
||||
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
|
||||
"TEMPLATE_SELECTED_SUBTITLE": "Configurar modelo: {templateName}"
|
||||
},
|
||||
"PICKER": {
|
||||
"SEARCH_PLACEHOLDER": "Pesquisar modelos",
|
||||
"NO_TEMPLATES_FOUND": "Não há templates encontrados para",
|
||||
"HEADER": "Header",
|
||||
"BODY": "Body",
|
||||
"FOOTER": "Footer",
|
||||
"BUTTONS": "Buttons",
|
||||
"HEADER": "Cabeçalho",
|
||||
"BODY": "Corpo",
|
||||
"FOOTER": "Rodapé",
|
||||
"BUTTONS": "Botões",
|
||||
"CATEGORY": "Categoria",
|
||||
"MEDIA_CONTENT": "Media Content",
|
||||
"MEDIA_CONTENT_FALLBACK": "media content",
|
||||
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
|
||||
"REFRESH_BUTTON": "Refresh templates",
|
||||
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
||||
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
||||
"MEDIA_CONTENT": "Conteúdo de Mídia",
|
||||
"MEDIA_CONTENT_FALLBACK": "conteúdo de mídia",
|
||||
"NO_TEMPLATES_AVAILABLE": "Não há modelos disponíveis do WhatsApp. Clique em atualizar para sincronizar os modelos do WhatsApp.",
|
||||
"REFRESH_BUTTON": "Atualizar modelos",
|
||||
"REFRESH_SUCCESS": "Atualização de modelos iniciada. Pode levar alguns minutos para atualizar.",
|
||||
"REFRESH_ERROR": "Falha ao atualizar os modelos. Por favor, tente novamente.",
|
||||
"LABELS": {
|
||||
"LANGUAGE": "Idioma",
|
||||
"TEMPLATE_BODY": "Conteúdo do Template",
|
||||
@@ -33,14 +33,14 @@
|
||||
"GO_BACK_LABEL": "Voltar",
|
||||
"SEND_MESSAGE_LABEL": "Enviar Mensagem",
|
||||
"FORM_ERROR_MESSAGE": "Por favor, preencha todas as variáveis antes de enviar",
|
||||
"MEDIA_HEADER_LABEL": "{type} Header",
|
||||
"OTP_CODE": "Enter 4-8 digit OTP",
|
||||
"EXPIRY_MINUTES": "Enter expiry minutes",
|
||||
"BUTTON_PARAMETERS": "Button Parameters",
|
||||
"BUTTON_LABEL": "Button {index}",
|
||||
"COUPON_CODE": "Enter coupon code (max 15 chars)",
|
||||
"MEDIA_URL_LABEL": "Enter {type} URL",
|
||||
"BUTTON_PARAMETER": "Enter button parameter"
|
||||
"MEDIA_HEADER_LABEL": "Cabeçalho {type}",
|
||||
"OTP_CODE": "Digite OTP de 4 a 8 dígitos",
|
||||
"EXPIRY_MINUTES": "Digite os minutos de expiração",
|
||||
"BUTTON_PARAMETERS": "Parâmetros do botão",
|
||||
"BUTTON_LABEL": "Botão {index}",
|
||||
"COUPON_CODE": "Digite o código do cupom (máx. 15 caracteres)",
|
||||
"MEDIA_URL_LABEL": "Digite a URL {type}",
|
||||
"BUTTON_PARAMETER": "Insira o parâmetro do botão"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { picoSearch } from '@scmmishra/pico-search';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
@@ -20,6 +21,7 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
const assistantId = route.params.assistantId;
|
||||
|
||||
const uiFlags = useMapGetter('captainScenarios/getUIFlags');
|
||||
@@ -42,35 +44,25 @@ const breadcrumbItems = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const TOOL_LINK_REGEX = /\[([^\]]+)]\(tool:\/\/.+?\)/g;
|
||||
const LINK_INSTRUCTION_CLASS =
|
||||
'[&_a[href^="tool://"]]:text-n-iris-11 [&_a:not([href^="tool://"])]:text-n-slate-12 [&_a]:pointer-events-none [&_a]:cursor-default';
|
||||
|
||||
const renderInstruction = instruction => () =>
|
||||
h('span', {
|
||||
class: 'text-sm text-n-slate-12 py-4',
|
||||
innerHTML: instruction.replace(
|
||||
TOOL_LINK_REGEX,
|
||||
(_, title) =>
|
||||
`<span class="text-n-iris-11 font-medium">@${title.replace(/^@/, '')}</span>`
|
||||
),
|
||||
class: `text-sm text-n-slate-12 py-4 prose prose-sm min-w-0 break-words ${LINK_INSTRUCTION_CLASS}`,
|
||||
innerHTML: instruction,
|
||||
});
|
||||
|
||||
// Suggested example scenarios for quick add
|
||||
const scenariosExample = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Refund Order',
|
||||
description: 'User encountered a technical issue or error message.',
|
||||
title: 'Prospective Buyer',
|
||||
description:
|
||||
'Handle customers who are showing interest in purchasing a license',
|
||||
instruction:
|
||||
'Ask for steps to reproduce + browser/app version. Use [Known Issues](tool://known_issues) to check if it’s a known bug. File with [Create Bug Report](tool://bug_report_create) if new.',
|
||||
tools: ['create_bug_report', 'known_issues'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Product Recommendation',
|
||||
description: 'User is unsure which product or service to choose.',
|
||||
instruction:
|
||||
'Ask 2–3 clarifying questions. Use [Product Match](tool://product_match[user_needs]) and suggest 2–3 options with pros/cons. Link to compare page if available.',
|
||||
tools: ['product_match[user_needs]'],
|
||||
'If someone is interested in purchasing a license, ask them for following:\n\n1. How many licenses are they willing to purchase?\n2. Are they migrating from another platform?\n. Once these details are collected, do the following steps\n1. add a private note to with the information you collected using [Add Private Note](tool://add_private_note)\n2. Add label "sales" to the contact using [Add Label to Conversation](tool://add_label_to_conversation)\n3. Reply saying "one of us will reach out soon" and provide an estimated timeline for the response and [Handoff to Human](tool://handoff)',
|
||||
tools: ['add_private_note', 'add_label_to_conversation', 'handoff'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -248,7 +240,9 @@ onMounted(() => {
|
||||
<span class="text-sm text-n-slate-11 mt-2">
|
||||
{{ item.description }}
|
||||
</span>
|
||||
<component :is="renderInstruction(item.instruction)" />
|
||||
<component
|
||||
:is="renderInstruction(formatMessage(item.instruction, false))"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11 font-medium mb-1">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
|
||||
{{ item.tools?.map(tool => `@${tool}`).join(', ') }}
|
||||
|
||||
@@ -11,6 +11,7 @@ import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
|
||||
|
||||
import {
|
||||
isAConversationRoute,
|
||||
@@ -28,6 +29,7 @@ export default {
|
||||
ComposeConversation,
|
||||
SocialIcons,
|
||||
ContactMergeModal,
|
||||
VoiceCallButton,
|
||||
},
|
||||
props: {
|
||||
contact: {
|
||||
@@ -278,6 +280,14 @@ export default {
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
<VoiceCallButton
|
||||
:phone="contact.phone_number"
|
||||
icon="i-ri-phone-fill"
|
||||
size="sm"
|
||||
:tooltip-label="$t('CONTACT_PANEL.CALL')"
|
||||
slate
|
||||
faded
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
|
||||
icon="i-ph-pencil-simple"
|
||||
|
||||
@@ -55,15 +55,8 @@ export default {
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', [
|
||||
'sendMessage',
|
||||
'sendAttachment',
|
||||
'clearConversations',
|
||||
]),
|
||||
...mapActions('conversationAttributes', [
|
||||
'getAttributes',
|
||||
'clearConversationAttributes',
|
||||
]),
|
||||
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
|
||||
...mapActions('conversationAttributes', ['getAttributes']),
|
||||
async handleSendMessage(content) {
|
||||
await this.sendMessage({
|
||||
content,
|
||||
@@ -84,8 +77,6 @@ export default {
|
||||
this.inReplyTo = null;
|
||||
},
|
||||
startNewConversation() {
|
||||
this.clearConversations();
|
||||
this.clearConversationAttributes();
|
||||
this.replaceRoute('prechat-form');
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
"NOT_AVAILABLE": "Indisponível"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
import PreChatForm from '../components/PreChat/Form.vue';
|
||||
import configMixin from '../mixins/configMixin';
|
||||
import routerMixin from '../mixins/routerMixin';
|
||||
@@ -19,6 +20,8 @@ export default {
|
||||
emitter.off(ON_CONVERSATION_CREATED, this.handleConversationCreated);
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', ['clearConversations']),
|
||||
...mapActions('conversationAttributes', ['clearConversationAttributes']),
|
||||
handleConversationCreated() {
|
||||
// Redirect to messages page after conversation is created
|
||||
this.replaceRoute('messages');
|
||||
@@ -48,6 +51,8 @@ export default {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.clearConversations();
|
||||
this.clearConversationAttributes();
|
||||
this.$store.dispatch('conversation/createConversation', {
|
||||
fullName: fullName,
|
||||
emailAddress: emailAddress,
|
||||
|
||||
@@ -61,6 +61,7 @@ class Account < ApplicationRecord
|
||||
has_many :agent_bots, dependent: :destroy_async
|
||||
has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api'
|
||||
has_many :articles, dependent: :destroy_async, class_name: '::Article'
|
||||
has_many :assignment_policies, dependent: :destroy_async
|
||||
has_many :automation_rules, dependent: :destroy_async
|
||||
has_many :macros, dependent: :destroy_async
|
||||
has_many :campaigns, dependent: :destroy_async
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: assignment_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# assignment_order :integer default(0), not null
|
||||
# conversation_priority :integer default("earliest_created"), not null
|
||||
# description :text
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# fair_distribution_limit :integer default(100), not null
|
||||
# fair_distribution_window :integer default(3600), not null
|
||||
# name :string(255) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_assignment_policies_on_account_id (account_id)
|
||||
# index_assignment_policies_on_account_id_and_name (account_id,name) UNIQUE
|
||||
# index_assignment_policies_on_enabled (enabled)
|
||||
#
|
||||
class AssignmentPolicy < ApplicationRecord
|
||||
belongs_to :account
|
||||
has_many :inbox_assignment_policies, dependent: :destroy
|
||||
has_many :inboxes, through: :inbox_assignment_policies
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :fair_distribution_limit, numericality: { greater_than: 0 }
|
||||
validates :fair_distribution_window, numericality: { greater_than: 0 }
|
||||
|
||||
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
|
||||
|
||||
enum assignment_order: { round_robin: 0 } unless ChatwootApp.enterprise?
|
||||
end
|
||||
|
||||
AssignmentPolicy.include_mod_with('Concerns::AssignmentPolicy')
|
||||
@@ -67,6 +67,8 @@ class Inbox < ApplicationRecord
|
||||
has_many :conversations, dependent: :destroy_async
|
||||
has_many :messages, dependent: :destroy_async
|
||||
|
||||
has_one :inbox_assignment_policy, dependent: :destroy
|
||||
has_one :assignment_policy, through: :inbox_assignment_policy
|
||||
has_one :agent_bot_inbox, dependent: :destroy_async
|
||||
has_one :agent_bot, through: :agent_bot_inbox
|
||||
has_many :webhooks, dependent: :destroy_async
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: inbox_assignment_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# assignment_policy_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id)
|
||||
# index_inbox_assignment_policies_on_inbox_id (inbox_id) UNIQUE
|
||||
#
|
||||
class InboxAssignmentPolicy < ApplicationRecord
|
||||
belongs_to :inbox
|
||||
belongs_to :assignment_policy
|
||||
|
||||
validates :inbox_id, uniqueness: true
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class AssignmentPolicyPolicy < ApplicationPolicy
|
||||
def index?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def show?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def create?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
json.id assignment_policy.id
|
||||
json.name assignment_policy.name
|
||||
json.description assignment_policy.description
|
||||
json.assignment_order assignment_policy.assignment_order
|
||||
json.conversation_priority assignment_policy.conversation_priority
|
||||
json.fair_distribution_limit assignment_policy.fair_distribution_limit
|
||||
json.fair_distribution_window assignment_policy.fair_distribution_window
|
||||
json.enabled assignment_policy.enabled
|
||||
json.created_at assignment_policy.created_at.to_i
|
||||
json.updated_at assignment_policy.updated_at.to_i
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'assignment_policy', assignment_policy: @assignment_policy
|
||||
@@ -0,0 +1,5 @@
|
||||
json.id @inbox_assignment_policy.id
|
||||
json.inbox_id @inbox_assignment_policy.inbox_id
|
||||
json.assignment_policy_id @inbox_assignment_policy.assignment_policy_id
|
||||
json.created_at @inbox_assignment_policy.created_at.to_i
|
||||
json.updated_at @inbox_assignment_policy.updated_at.to_i
|
||||
@@ -0,0 +1,3 @@
|
||||
json.inboxes @inboxes do |inbox|
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: inbox
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
json.array! @assignment_policies do |assignment_policy|
|
||||
json.partial! 'assignment_policy', assignment_policy: assignment_policy
|
||||
end
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'assignment_policy', assignment_policy: @assignment_policy
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'assignment_policy', assignment_policy: @assignment_policy
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/accounts/assignment_policies/assignment_policy', formats: [:json], assignment_policy: @assignment_policy
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/accounts/assignment_policies/assignment_policy', formats: [:json], assignment_policy: @assignment_policy
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.4.0'
|
||||
version: '4.5.1'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -12,7 +12,7 @@ Bundler.require(*Rails.groups)
|
||||
# We rely on DOTENV to load the environment variables
|
||||
# We need these environment variables to load the specific APM agent
|
||||
Dotenv::Rails.load
|
||||
require 'ddtrace' if ENV.fetch('DD_TRACE_AGENT_URL', false).present?
|
||||
require 'datadog' if ENV.fetch('DD_TRACE_AGENT_URL', false).present?
|
||||
require 'elastic-apm' if ENV.fetch('ELASTIC_APM_SECRET_TOKEN', false).present?
|
||||
require 'scout_apm' if ENV.fetch('SCOUT_KEY', false).present?
|
||||
|
||||
|
||||
@@ -191,3 +191,7 @@
|
||||
display_name: CRM V2
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: assignment_v2
|
||||
display_name: Assignment V2
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
|
||||
@@ -53,6 +53,8 @@ en:
|
||||
email_already_exists: 'You have already signed up for an account with %{email}'
|
||||
invalid_params: 'Invalid, please check the signup paramters and try again'
|
||||
failed: Signup failed
|
||||
assignment_policy:
|
||||
not_found: Assignment policy not found
|
||||
data_import:
|
||||
data_type:
|
||||
invalid: Invalid data type
|
||||
@@ -96,6 +98,9 @@ en:
|
||||
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
|
||||
custom_attribute_definition:
|
||||
key_conflict: The provided key is not allowed as it might conflict with default attributes.
|
||||
leave_records:
|
||||
cannot_update_non_pending: Cannot update non-pending leave record
|
||||
cannot_delete: Cannot delete this leave record
|
||||
reports:
|
||||
period: Reporting period %{since} to %{until}
|
||||
utc_warning: The report generated is in UTC timezone
|
||||
|
||||
+15
-15
@@ -20,9 +20,9 @@ pt_BR:
|
||||
hello: 'Olá, mundo'
|
||||
inbox:
|
||||
reauthorization:
|
||||
success: 'Channel reauthorized successfully'
|
||||
not_required: 'Reauthorization is not required for this inbox'
|
||||
invalid_channel: 'Invalid channel type for reauthorization'
|
||||
success: 'Canal reautenticado com sucesso'
|
||||
not_required: 'Reautenticação não é necessária para esta caixa de entrada'
|
||||
invalid_channel: 'Tipo de canal inválido para reautenticar'
|
||||
messages:
|
||||
reset_password_success: Legal! A solicitação de alteração de senha foi bem sucedida. Verifique seu e-mail para obter instruções.
|
||||
reset_password_failure: Uh ho! Não conseguimos encontrar nenhum usuário com o e-mail especificado.
|
||||
@@ -59,12 +59,12 @@ pt_BR:
|
||||
slack:
|
||||
invalid_channel_id: 'Canal de slack inválido. Por favor, tente novamente'
|
||||
whatsapp:
|
||||
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
|
||||
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
|
||||
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
|
||||
token_exchange_failed: 'Falha ao trocar o código por um token de acesso. Por favor, tente novamente.'
|
||||
invalid_token_permissions: 'O token de acesso não tem as permissões necessárias para o WhatsApp.'
|
||||
phone_info_fetch_failed: 'Falha ao obter a informação do número de telefone. Por favor, tente novamente.'
|
||||
reauthorization:
|
||||
generic: 'Failed to reauthorize WhatsApp. Please try again.'
|
||||
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
|
||||
generic: 'Falha ao reautenticar o WhatsApp. Por favor, tente novamente.'
|
||||
not_supported: 'Reautenticação não é suportado por este tipo de canal WhatsApp.'
|
||||
inboxes:
|
||||
imap:
|
||||
socket_error: Por favor, verifique a conexão de rede, endereço IMAP e tente novamente.
|
||||
@@ -257,8 +257,8 @@ pt_BR:
|
||||
description: 'Crie issues em Linear diretamente da sua janela de conversa. Alternativamente, vincule as issues lineares existentes para um processo de rastreamento de problemas mais simples e eficiente.'
|
||||
notion:
|
||||
name: 'Notion'
|
||||
short_description: 'Integrate databases, documents and pages directly with Captain.'
|
||||
description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
|
||||
short_description: 'Integre banco de dados, documentos e páginas diretamente com o Capitão.'
|
||||
description: 'Conecte o seu espaço de trabalho Notion para permitir que o Capitão acesse e gere respostas inteligentes usando o conteúdo de seus bancos de dados, documentos e páginas para fornecer suporte ao cliente mais contextual.'
|
||||
shopify:
|
||||
name: 'Shopify'
|
||||
short_description: 'Acessar detalhes do pedido e dados de clientes da sua loja Shopify.'
|
||||
@@ -359,9 +359,9 @@ pt_BR:
|
||||
portals:
|
||||
send_instructions:
|
||||
email_required: 'E-mail é obrigatório'
|
||||
invalid_email_format: 'Invalid email format'
|
||||
custom_domain_not_configured: 'Custom domain is not configured'
|
||||
instructions_sent_successfully: 'Instructions sent successfully'
|
||||
subject: 'Finish setting up %{custom_domain}'
|
||||
invalid_email_format: 'Formato inválido de e-mail'
|
||||
custom_domain_not_configured: 'Domínio personalizado não está configurado'
|
||||
instructions_sent_successfully: 'Instruções enviadas com sucesso'
|
||||
subject: 'Termine de configurar %{custom_domain}'
|
||||
ssl_status:
|
||||
custom_domain_not_configured: 'Custom domain is not configured'
|
||||
custom_domain_not_configured: 'Domínio personalizado não está configurado'
|
||||
|
||||
@@ -96,6 +96,12 @@ Rails.application.routes.draw do
|
||||
post :execute, on: :member
|
||||
end
|
||||
resources :sla_policies, only: [:index, :create, :show, :update, :destroy]
|
||||
resources :leave_records, only: [:index, :create, :show, :update, :destroy] do
|
||||
member do
|
||||
patch :approve
|
||||
patch :reject
|
||||
end
|
||||
end
|
||||
resources :custom_roles, only: [:index, :create, :show, :update, :destroy]
|
||||
resources :campaigns, only: [:index, :create, :show, :update, :destroy]
|
||||
resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy]
|
||||
@@ -217,6 +223,15 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
# Assignment V2 Routes
|
||||
resources :assignment_policies do
|
||||
resources :inboxes, only: [:index, :create, :destroy], module: :assignment_policies
|
||||
end
|
||||
|
||||
resources :inboxes, only: [] do
|
||||
resource :assignment_policy, only: [:show, :create, :destroy], module: :inboxes
|
||||
end
|
||||
|
||||
namespace :twitter do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateLeaves < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :leaves do |t|
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
class AddFeatureCitationToAssistantConfig < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
return unless ChatwootApp.enterprise?
|
||||
|
||||
Captain::Assistant.find_each do |assistant|
|
||||
assistant.update!(
|
||||
config: assistant.config.merge('feature_citation' => true)
|
||||
@@ -8,6 +10,8 @@ class AddFeatureCitationToAssistantConfig < ActiveRecord::Migration[7.1]
|
||||
end
|
||||
|
||||
def down
|
||||
return unless ChatwootApp.enterprise?
|
||||
|
||||
Captain::Assistant.find_each do |assistant|
|
||||
config = assistant.config.dup
|
||||
config.delete('feature_citation')
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class RenameLeaveTableToLeaveRecords < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
rename_table :leaves, :leave_records
|
||||
end
|
||||
end
|
||||
+6
-6
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_08_20_083315) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -848,7 +848,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
|
||||
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "leaves", force: :cascade do |t|
|
||||
create_table "leave_records", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.date "start_date", null: false
|
||||
@@ -860,10 +860,10 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
|
||||
t.datetime "approved_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "status"], name: "index_leaves_on_account_id_and_status"
|
||||
t.index ["account_id"], name: "index_leaves_on_account_id"
|
||||
t.index ["approved_by_id"], name: "index_leaves_on_approved_by_id"
|
||||
t.index ["user_id"], name: "index_leaves_on_user_id"
|
||||
t.index ["account_id", "status"], name: "index_leave_records_on_account_id_and_status"
|
||||
t.index ["account_id"], name: "index_leave_records_on_account_id"
|
||||
t.index ["approved_by_id"], name: "index_leave_records_on_approved_by_id"
|
||||
t.index ["user_id"], name: "index_leave_records_on_user_id"
|
||||
end
|
||||
|
||||
create_table "macros", force: :cascade do |t|
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
class Api::V1::Accounts::LeaveRecordsController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
before_action :check_authorization
|
||||
before_action :fetch_leave_record, only: [:show, :update, :destroy, :approve, :reject]
|
||||
before_action :ensure_pending_status, only: [:update]
|
||||
before_action :ensure_can_be_cancelled, only: [:destroy]
|
||||
|
||||
def index
|
||||
@leave_records = policy_scope(Current.account.leave_records)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@leave_record = Current.account.leave_records.create!(permitted_params.merge(user: current_user))
|
||||
end
|
||||
|
||||
def update
|
||||
@leave_record.update!(permitted_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@leave_record.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
def approve
|
||||
@leave_record.approve!(current_user)
|
||||
render :show
|
||||
end
|
||||
|
||||
def reject
|
||||
@leave_record.reject!(current_user)
|
||||
render :show
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_params
|
||||
params.require(:leave_record).permit(:start_date, :end_date, :leave_type, :reason)
|
||||
end
|
||||
|
||||
def fetch_leave_record
|
||||
@leave_record = policy_scope(Current.account.leave_records).find(params[:id])
|
||||
end
|
||||
|
||||
def ensure_pending_status
|
||||
return if @leave_record.pending?
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.leave_records.cannot_update_non_pending'))
|
||||
end
|
||||
|
||||
def ensure_can_be_cancelled
|
||||
return if @leave_record.can_be_cancelled?
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.leave_records.cannot_delete'))
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,7 @@ module Enterprise::Concerns::Account
|
||||
has_many :sla_policies, dependent: :destroy_async
|
||||
has_many :applied_slas, dependent: :destroy_async
|
||||
has_many :custom_roles, dependent: :destroy_async
|
||||
has_many :leave_records, dependent: :destroy_async
|
||||
|
||||
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
|
||||
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Concerns::AssignmentPolicy
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
enum assignment_order: { round_robin: 0, balanced: 1 } if ChatwootApp.enterprise?
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,7 @@ module Enterprise::Concerns::User
|
||||
|
||||
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :leave_records, dependent: :destroy_async
|
||||
end
|
||||
|
||||
def ensure_installation_pricing_plan_quantity
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: leave_records
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# approved_at :datetime
|
||||
# end_date :date not null
|
||||
# leave_type :integer default("annual"), not null
|
||||
# reason :text
|
||||
# start_date :date not null
|
||||
# status :integer default("pending"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# approved_by_id :bigint
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_leave_records_on_account_id (account_id)
|
||||
# index_leave_records_on_account_id_and_status (account_id,status)
|
||||
# index_leave_records_on_approved_by_id (approved_by_id)
|
||||
# index_leave_records_on_user_id (user_id)
|
||||
#
|
||||
class LeaveRecord < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :user
|
||||
belongs_to :approved_by, class_name: 'User', optional: true
|
||||
|
||||
enum leave_type: {
|
||||
annual: 0,
|
||||
sick: 1,
|
||||
personal: 2,
|
||||
maternity: 3,
|
||||
paternity: 4,
|
||||
emergency: 5,
|
||||
bereavement: 6,
|
||||
study: 7,
|
||||
other: 8
|
||||
}
|
||||
|
||||
enum status: {
|
||||
pending: 0,
|
||||
approved: 1,
|
||||
rejected: 2,
|
||||
cancelled: 3
|
||||
}
|
||||
|
||||
validates :start_date, :end_date, presence: true
|
||||
validates :leave_type, :status, presence: true
|
||||
validate :end_date_after_start_date
|
||||
validate :future_dates_for_pending_leaves
|
||||
validate :approved_by_is_admin
|
||||
|
||||
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
||||
scope :for_user, ->(user_id) { where(user_id: user_id) }
|
||||
scope :by_status, ->(status) { where(status: status) }
|
||||
scope :by_leave_type, ->(leave_type) { where(leave_type: leave_type) }
|
||||
scope :in_date_range, ->(start_date, end_date) { where('start_date <= ? AND end_date >= ?', end_date, start_date) }
|
||||
|
||||
def approve!(approved_by_user)
|
||||
update!(status: :approved, approved_by_id: approved_by_user.id, approved_at: Time.current)
|
||||
end
|
||||
|
||||
def reject!(approved_by_user)
|
||||
update!(status: :rejected, approved_by_id: approved_by_user.id, approved_at: Time.current)
|
||||
end
|
||||
|
||||
def duration_in_days
|
||||
return 0 unless start_date && end_date
|
||||
|
||||
(end_date - start_date).to_i + 1
|
||||
end
|
||||
|
||||
def can_be_cancelled?
|
||||
pending? || (approved? && start_date > Date.current)
|
||||
end
|
||||
|
||||
def overlaps_with?(other_leave)
|
||||
return false unless other_leave.is_a?(LeaveRecord)
|
||||
|
||||
start_date <= other_leave.end_date && end_date >= other_leave.start_date
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def end_date_after_start_date
|
||||
return unless start_date && end_date
|
||||
|
||||
errors.add(:end_date, 'must be after start date') if end_date < start_date
|
||||
end
|
||||
|
||||
def future_dates_for_pending_leaves
|
||||
return unless pending?
|
||||
|
||||
errors.add(:start_date, 'must be in the future') if start_date && start_date <= Date.current
|
||||
end
|
||||
|
||||
def approved_by_is_admin
|
||||
return unless approved_by_id && approved_by
|
||||
|
||||
account_user = account.account_users.find_by(user: approved_by)
|
||||
errors.add(:approved_by, 'must be an administrator') unless account_user&.administrator?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
class LeaveRecordPolicy < ApplicationPolicy
|
||||
def index?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
|
||||
def show?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
|
||||
def create?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
|
||||
def approve?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def reject?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
class Scope
|
||||
attr_reader :user_context, :user, :scope, :account, :account_user
|
||||
|
||||
def initialize(user_context, scope)
|
||||
@user_context = user_context
|
||||
@user = user_context[:user]
|
||||
@account = user_context[:account]
|
||||
@account_user = user_context[:account_user]
|
||||
@scope = scope
|
||||
end
|
||||
|
||||
def resolve
|
||||
if @account_user.administrator?
|
||||
scope.includes(:user, :approved_by)
|
||||
else
|
||||
scope.where(user: @user).includes(:user, :approved_by)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/leave_record', formats: [:json], leave_record: @leave_record
|
||||
@@ -0,0 +1,3 @@
|
||||
json.array! @leave_records do |leave_record|
|
||||
json.partial! 'api/v1/models/leave_record', formats: [:json], leave_record: leave_record
|
||||
end
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/leave_record', formats: [:json], leave_record: @leave_record
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/leave_record', formats: [:json], leave_record: @leave_record
|
||||
@@ -0,0 +1,26 @@
|
||||
json.id leave_record.id
|
||||
json.start_date leave_record.start_date
|
||||
json.end_date leave_record.end_date
|
||||
json.leave_type leave_record.leave_type
|
||||
json.status leave_record.status
|
||||
json.reason leave_record.reason
|
||||
json.duration_in_days leave_record.duration_in_days
|
||||
json.approved_at leave_record.approved_at&.to_i
|
||||
json.created_at leave_record.created_at.to_i
|
||||
json.updated_at leave_record.updated_at.to_i
|
||||
|
||||
json.user do
|
||||
json.id leave_record.user.id
|
||||
json.name leave_record.user.name
|
||||
json.email leave_record.user.email
|
||||
end
|
||||
|
||||
if leave_record.approved_by.present?
|
||||
json.approved_by do
|
||||
json.id leave_record.approved_by.id
|
||||
json.name leave_record.approved_by.name
|
||||
json.email leave_record.approved_by.email
|
||||
end
|
||||
else
|
||||
json.approved_by nil
|
||||
end
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.4.0",
|
||||
"version": "4.5.1",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Assignment Policy Inboxes API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account_id}/assignment_policies/{assignment_policy_id}/inboxes' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}/inboxes"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated admin' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
context 'when assignment policy has associated inboxes' do
|
||||
before do
|
||||
inbox1 = create(:inbox, account: account)
|
||||
inbox2 = create(:inbox, account: account)
|
||||
create(:inbox_assignment_policy, inbox: inbox1, assignment_policy: assignment_policy)
|
||||
create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: assignment_policy)
|
||||
end
|
||||
|
||||
it 'returns all inboxes associated with the assignment policy' do
|
||||
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}/inboxes",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['inboxes']).to be_an(Array)
|
||||
expect(json_response['inboxes'].length).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assignment policy has no associated inboxes' do
|
||||
it 'returns empty array' do
|
||||
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}/inboxes",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['inboxes']).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}/inboxes",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,326 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Assignment Policies API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/assignment_policies' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/assignment_policies"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated admin' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
create_list(:assignment_policy, 3, account: account)
|
||||
end
|
||||
|
||||
it 'returns all assignment policies for the account' do
|
||||
get "/api/v1/accounts/#{account.id}/assignment_policies",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response.length).to eq(3)
|
||||
expect(json_response.first.keys).to include('id', 'name', 'description')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/assignment_policies",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/assignment_policies/:id' do
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account) }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated admin' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns the assignment policy' do
|
||||
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['id']).to eq(assignment_policy.id)
|
||||
expect(json_response['name']).to eq(assignment_policy.name)
|
||||
end
|
||||
|
||||
it 'returns not found for non-existent policy' do
|
||||
get "/api/v1/accounts/#{account.id}/assignment_policies/999999",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/assignment_policies' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
assignment_policy: {
|
||||
name: 'New Assignment Policy',
|
||||
description: 'Policy for new team',
|
||||
conversation_priority: 'longest_waiting',
|
||||
fair_distribution_limit: 15,
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/assignment_policies", params: valid_params
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated admin' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'creates a new assignment policy' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/assignment_policies",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_params,
|
||||
as: :json
|
||||
end.to change(AssignmentPolicy, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['name']).to eq('New Assignment Policy')
|
||||
expect(json_response['conversation_priority']).to eq('longest_waiting')
|
||||
end
|
||||
|
||||
it 'creates policy with minimal required params' do
|
||||
minimal_params = { assignment_policy: { name: 'Minimal Policy' } }
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/assignment_policies",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: minimal_params,
|
||||
as: :json
|
||||
end.to change(AssignmentPolicy, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'prevents duplicate policy names within account' do
|
||||
create(:assignment_policy, account: account, name: 'Duplicate Policy')
|
||||
duplicate_params = { assignment_policy: { name: 'Duplicate Policy' } }
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/assignment_policies",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: duplicate_params,
|
||||
as: :json
|
||||
end.not_to change(AssignmentPolicy, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'validates required fields' do
|
||||
invalid_params = { assignment_policy: { name: '' } }
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/assignment_policies",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: invalid_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/assignment_policies",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: valid_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/{account.id}/assignment_policies/:id' do
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, name: 'Original Policy') }
|
||||
let(:update_params) do
|
||||
{
|
||||
assignment_policy: {
|
||||
name: 'Updated Policy',
|
||||
description: 'Updated description',
|
||||
fair_distribution_limit: 20
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
|
||||
params: update_params
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated admin' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'updates the assignment policy' do
|
||||
put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: update_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
assignment_policy.reload
|
||||
expect(assignment_policy.name).to eq('Updated Policy')
|
||||
expect(assignment_policy.fair_distribution_limit).to eq(20)
|
||||
end
|
||||
|
||||
it 'allows partial updates' do
|
||||
partial_params = { assignment_policy: { enabled: false } }
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: partial_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(assignment_policy.reload.enabled).to be(false)
|
||||
expect(assignment_policy.name).to eq('Original Policy') # unchanged
|
||||
end
|
||||
|
||||
it 'prevents duplicate names during update' do
|
||||
create(:assignment_policy, account: account, name: 'Existing Policy')
|
||||
duplicate_params = { assignment_policy: { name: 'Existing Policy' } }
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: duplicate_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'returns not found for non-existent policy' do
|
||||
put "/api/v1/accounts/#{account.id}/assignment_policies/999999",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: update_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: update_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/assignment_policies/:id' do
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account) }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated admin' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'deletes the assignment policy' do
|
||||
assignment_policy # create it first
|
||||
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(AssignmentPolicy, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'cascades deletion to associated inbox assignment policies' do
|
||||
inbox = create(:inbox, account: account)
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(InboxAssignmentPolicy, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'returns not found for non-existent policy' do
|
||||
delete "/api/v1/accounts/#{account.id}/assignment_policies/999999",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,195 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Inbox Assignment Policies API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account_id}/inboxes/{inbox_id}/assignment_policy' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated admin' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
context 'when inbox has an assignment policy' do
|
||||
before do
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
end
|
||||
|
||||
it 'returns the assignment policy for the inbox' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['id']).to eq(assignment_policy.id)
|
||||
expect(json_response['name']).to eq(assignment_policy.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox has no assignment policy' do
|
||||
it 'returns not found' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account_id}/inboxes/{inbox_id}/assignment_policy' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
|
||||
params: { assignment_policy_id: assignment_policy.id }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated admin' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'assigns a policy to the inbox' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
|
||||
params: { assignment_policy_id: assignment_policy.id },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(InboxAssignmentPolicy, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['id']).to eq(assignment_policy.id)
|
||||
end
|
||||
|
||||
it 'replaces existing assignment policy for inbox' do
|
||||
other_policy = create(:assignment_policy, account: account)
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: other_policy)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
|
||||
params: { assignment_policy_id: assignment_policy.id },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to change(InboxAssignmentPolicy, :count)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(inbox.reload.inbox_assignment_policy.assignment_policy).to eq(assignment_policy)
|
||||
end
|
||||
|
||||
it 'returns not found for invalid assignment policy' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
|
||||
params: { assignment_policy_id: 999_999 },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns not found for invalid inbox' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/999999/assignment_policy",
|
||||
params: { assignment_policy_id: assignment_policy.id },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
|
||||
params: { assignment_policy_id: assignment_policy.id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account_id}/inboxes/{inbox_id}/assignment_policy' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated admin' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
context 'when inbox has an assignment policy' do
|
||||
before do
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
end
|
||||
|
||||
it 'removes the assignment policy from inbox' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(InboxAssignmentPolicy, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(inbox.reload.inbox_assignment_policy).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox has no assignment policy' do
|
||||
it 'returns error' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to change(InboxAssignmentPolicy, :count)
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns not found for invalid inbox' do
|
||||
delete "/api/v1/accounts/#{account.id}/inboxes/999999/assignment_policy",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,152 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'LeaveRecords API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:other_agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/leave_records' do
|
||||
before do
|
||||
create(:leave_record, account: account, user: agent)
|
||||
create(:leave_record, account: account, user: other_agent)
|
||||
end
|
||||
|
||||
it 'allows admins to see all leave records' do
|
||||
get "/api/v1/accounts/#{account.id}/leave_records",
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response.length).to eq(2)
|
||||
end
|
||||
|
||||
it 'allows agents to see only their own leave records' do
|
||||
get "/api/v1/accounts/#{account.id}/leave_records",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response.length).to eq(1)
|
||||
expect(json_response.first['user']['id']).to eq(agent.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/leave_records' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
leave_record: {
|
||||
start_date: 1.week.from_now.to_date,
|
||||
end_date: 2.weeks.from_now.to_date,
|
||||
leave_type: 'annual',
|
||||
reason: 'Family vacation'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a leave record for the current user' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/leave_records",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token
|
||||
end.to change(LeaveRecord, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response['user']['id']).to eq(agent.id)
|
||||
expect(json_response['status']).to eq('pending')
|
||||
end
|
||||
|
||||
it 'fails with invalid params' do
|
||||
post "/api/v1/accounts/#{account.id}/leave_records",
|
||||
params: { leave_record: { start_date: nil } },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/:account_id/leave_records/:id' do
|
||||
let(:leave_record) { create(:leave_record, account: account, user: agent, status: :pending) }
|
||||
let(:update_params) { { leave_record: { reason: 'Updated reason' } } }
|
||||
|
||||
it 'allows updating pending leave records' do
|
||||
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}",
|
||||
params: update_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response['reason']).to eq('Updated reason')
|
||||
end
|
||||
|
||||
it 'prevents updating approved leave records' do
|
||||
leave_record.update!(status: :approved)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}",
|
||||
params: update_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response['error']).to eq(I18n.t('errors.leave_records.cannot_update_non_pending'))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/leave_records/:id' do
|
||||
let!(:leave_record) { create(:leave_record, account: account, user: agent, status: :pending) }
|
||||
|
||||
it 'allows deleting cancellable leave records' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
end.to change(LeaveRecord, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'prevents deleting non-cancellable leave records' do
|
||||
leave_record.update!(status: :approved, start_date: Date.current, end_date: Date.current + 2.days)
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response['error']).to eq(I18n.t('errors.leave_records.cannot_delete'))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/:account_id/leave_records/:id/approve' do
|
||||
let(:leave_record) { create(:leave_record, account: account, user: agent, status: :pending) }
|
||||
|
||||
it 'allows admins to approve leave records' do
|
||||
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}/approve",
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response['status']).to eq('approved')
|
||||
expect(json_response['approved_by']['id']).to eq(admin.id)
|
||||
end
|
||||
|
||||
it 'denies agents from approving leave records' do
|
||||
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}/approve",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/:account_id/leave_records/:id/reject' do
|
||||
let(:leave_record) { create(:leave_record, account: account, user: agent, status: :pending) }
|
||||
|
||||
it 'allows admins to reject leave records' do
|
||||
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}/reject",
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response['status']).to eq('rejected')
|
||||
expect(json_response['approved_by']['id']).to eq(admin.id)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentPolicy do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'enum values' do
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account) }
|
||||
|
||||
describe 'assignment_order' do
|
||||
it 'can be set to balanced' do
|
||||
assignment_policy.update!(assignment_order: :balanced)
|
||||
expect(assignment_policy.assignment_order).to eq('balanced')
|
||||
expect(assignment_policy.round_robin?).to be false
|
||||
expect(assignment_policy.balanced?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,130 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe LeaveRecord, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:user) }
|
||||
it { is_expected.to belong_to(:approved_by).class_name('User').optional }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
subject { build(:leave_record, account: account, user: user) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:start_date) }
|
||||
it { is_expected.to validate_presence_of(:end_date) }
|
||||
it { is_expected.to validate_presence_of(:leave_type) }
|
||||
it { is_expected.to validate_presence_of(:status) }
|
||||
|
||||
it 'validates end_date is after start_date' do
|
||||
leave = build(:leave_record, account: account, user: user,
|
||||
start_date: Date.current + 2.days,
|
||||
end_date: Date.current)
|
||||
expect(leave).not_to be_valid
|
||||
expect(leave.errors[:end_date]).to include('must be after start date')
|
||||
end
|
||||
|
||||
it 'validates future dates for pending leaves' do
|
||||
leave = build(:leave_record, account: account, user: user,
|
||||
status: :pending,
|
||||
start_date: Date.current)
|
||||
expect(leave).not_to be_valid
|
||||
expect(leave.errors[:start_date]).to include('must be in the future')
|
||||
end
|
||||
|
||||
it 'validates approved_by is admin when present' do
|
||||
agent = create(:user, account: account, role: :agent)
|
||||
leave = build(:leave_record, account: account, user: user,
|
||||
approved_by: agent,
|
||||
status: :approved)
|
||||
expect(leave).not_to be_valid
|
||||
expect(leave.errors[:approved_by]).to include('must be an administrator')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#duration_in_days' do
|
||||
it 'calculates correct duration' do
|
||||
leave = build(:leave_record,
|
||||
start_date: Date.current + 1.day,
|
||||
end_date: Date.current + 5.days)
|
||||
expect(leave.duration_in_days).to eq(5)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#can_be_cancelled?' do
|
||||
it 'returns true for pending leaves' do
|
||||
leave = build(:leave_record, status: :pending)
|
||||
expect(leave.can_be_cancelled?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for approved future leaves' do
|
||||
leave = build(:leave_record, status: :approved,
|
||||
start_date: Date.current + 1.day)
|
||||
expect(leave.can_be_cancelled?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for approved ongoing leaves' do
|
||||
leave = build(:leave_record, status: :approved,
|
||||
start_date: Date.current)
|
||||
expect(leave.can_be_cancelled?).to be false
|
||||
end
|
||||
|
||||
it 'returns false for rejected leaves' do
|
||||
leave = build(:leave_record, status: :rejected)
|
||||
expect(leave.can_be_cancelled?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#approve!' do
|
||||
let(:leave) { create(:leave_record, account: account, user: user, status: :pending) }
|
||||
|
||||
it 'approves the leave and sets approved_by' do
|
||||
freeze_time do
|
||||
leave.approve!(admin)
|
||||
|
||||
expect(leave.status).to eq('approved')
|
||||
expect(leave.approved_by).to eq(admin)
|
||||
expect(leave.approved_at).to eq(Time.current)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#reject!' do
|
||||
let(:leave) { create(:leave_record, account: account, user: user, status: :pending) }
|
||||
|
||||
it 'rejects the leave and sets approved_by' do
|
||||
freeze_time do
|
||||
leave.reject!(admin)
|
||||
|
||||
expect(leave.status).to eq('rejected')
|
||||
expect(leave.approved_by).to eq(admin)
|
||||
expect(leave.approved_at).to eq(Time.current)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#overlaps_with?' do
|
||||
let(:leave1) do
|
||||
build(:leave_record,
|
||||
start_date: Date.current + 5.days,
|
||||
end_date: Date.current + 10.days)
|
||||
end
|
||||
|
||||
it 'detects overlapping leaves' do
|
||||
leave2 = build(:leave_record,
|
||||
start_date: Date.current + 7.days,
|
||||
end_date: Date.current + 12.days)
|
||||
expect(leave1.overlaps_with?(leave2)).to be true
|
||||
end
|
||||
|
||||
it 'returns false for non-overlapping leaves' do
|
||||
leave2 = build(:leave_record,
|
||||
start_date: Date.current + 11.days,
|
||||
end_date: Date.current + 15.days)
|
||||
expect(leave1.overlaps_with?(leave2)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe LeaveRecordPolicy, type: :policy do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:leave_record) { create(:leave_record, account: account, user: agent) }
|
||||
|
||||
describe 'permissions' do
|
||||
context 'when user is administrator' do
|
||||
let(:policy) { described_class.new(pundit_context(admin), leave_record) }
|
||||
|
||||
it 'permits all actions' do
|
||||
expect(policy.index?).to be true
|
||||
expect(policy.show?).to be true
|
||||
expect(policy.create?).to be true
|
||||
expect(policy.update?).to be true
|
||||
expect(policy.destroy?).to be true
|
||||
expect(policy.approve?).to be true
|
||||
expect(policy.reject?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is agent' do
|
||||
let(:policy) { described_class.new(pundit_context(agent), leave_record) }
|
||||
|
||||
it 'permits basic actions' do
|
||||
expect(policy.index?).to be true
|
||||
expect(policy.show?).to be true
|
||||
expect(policy.create?).to be true
|
||||
expect(policy.update?).to be true
|
||||
expect(policy.destroy?).to be true
|
||||
end
|
||||
|
||||
it 'denies approval actions' do
|
||||
expect(policy.approve?).to be false
|
||||
expect(policy.reject?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'scope' do
|
||||
let!(:agent_leave) { create(:leave_record, account: account, user: agent) }
|
||||
let!(:other_agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:other_leave) { create(:leave_record, account: account, user: other_agent) }
|
||||
|
||||
it 'returns all leaves for administrators' do
|
||||
scope = described_class::Scope.new(pundit_context(admin), LeaveRecord).resolve
|
||||
expect(scope).to include(agent_leave, other_leave)
|
||||
expect(scope.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns only own leaves for agents' do
|
||||
scope = described_class::Scope.new(pundit_context(agent), LeaveRecord).resolve
|
||||
expect(scope).to include(agent_leave)
|
||||
expect(scope).not_to include(other_leave)
|
||||
expect(scope.count).to eq(1)
|
||||
end
|
||||
|
||||
it 'includes associations for performance' do
|
||||
scope = described_class::Scope.new(pundit_context(admin), LeaveRecord).resolve
|
||||
expect(scope.includes_values).to include(:user, :approved_by)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pundit_context(user)
|
||||
{
|
||||
user: user,
|
||||
account: account,
|
||||
account_user: user.account_users.find_by(account: account)
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
FactoryBot.define do
|
||||
factory :assignment_policy do
|
||||
account
|
||||
sequence(:name) { |n| "Assignment Policy #{n}" }
|
||||
description { 'Test assignment policy description' }
|
||||
assignment_order { 0 }
|
||||
conversation_priority { 0 }
|
||||
fair_distribution_limit { 10 }
|
||||
fair_distribution_window { 3600 }
|
||||
enabled { true }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
FactoryBot.define do
|
||||
factory :inbox_assignment_policy do
|
||||
inbox
|
||||
assignment_policy
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
FactoryBot.define do
|
||||
factory :leave_record do
|
||||
account
|
||||
user
|
||||
start_date { 1.week.from_now.to_date }
|
||||
end_date { 2.weeks.from_now.to_date }
|
||||
leave_type { :annual }
|
||||
status { :pending }
|
||||
reason { 'Annual vacation leave' }
|
||||
|
||||
trait :sick do
|
||||
leave_type { :sick }
|
||||
reason { 'Sick leave for medical treatment' }
|
||||
end
|
||||
|
||||
trait :emergency do
|
||||
leave_type { :emergency }
|
||||
reason { 'Emergency family matter' }
|
||||
end
|
||||
|
||||
trait :other do
|
||||
leave_type { :other }
|
||||
reason { 'Other type of leave' }
|
||||
end
|
||||
|
||||
trait :approved do
|
||||
status { :approved }
|
||||
approved_by { association(:user) }
|
||||
approved_at { 1.day.ago }
|
||||
end
|
||||
|
||||
trait :rejected do
|
||||
status { :rejected }
|
||||
approved_by { association(:user) }
|
||||
approved_at { 1.day.ago }
|
||||
end
|
||||
|
||||
trait :past_dates do
|
||||
start_date { 2.weeks.ago.to_date }
|
||||
end_date { 1.week.ago.to_date }
|
||||
status { :approved } # Past dates should only be used with approved status
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentPolicy do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to have_many(:inbox_assignment_policies).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:inboxes).through(:inbox_assignment_policies) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
subject { build(:assignment_policy) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:account_id) }
|
||||
end
|
||||
|
||||
describe 'fair distribution validations' do
|
||||
it 'requires fair_distribution_limit to be greater than 0' do
|
||||
policy = build(:assignment_policy, fair_distribution_limit: 0)
|
||||
expect(policy).not_to be_valid
|
||||
expect(policy.errors[:fair_distribution_limit]).to include('must be greater than 0')
|
||||
end
|
||||
|
||||
it 'requires fair_distribution_window to be greater than 0' do
|
||||
policy = build(:assignment_policy, fair_distribution_window: -1)
|
||||
expect(policy).not_to be_valid
|
||||
expect(policy.errors[:fair_distribution_window]).to include('must be greater than 0')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'enum values' do
|
||||
let(:assignment_policy) { create(:assignment_policy) }
|
||||
|
||||
describe 'conversation_priority' do
|
||||
it 'can be set to earliest_created' do
|
||||
assignment_policy.update!(conversation_priority: :earliest_created)
|
||||
expect(assignment_policy.conversation_priority).to eq('earliest_created')
|
||||
expect(assignment_policy.earliest_created?).to be true
|
||||
end
|
||||
|
||||
it 'can be set to longest_waiting' do
|
||||
assignment_policy.update!(conversation_priority: :longest_waiting)
|
||||
expect(assignment_policy.conversation_priority).to eq('longest_waiting')
|
||||
expect(assignment_policy.longest_waiting?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'assignment_order' do
|
||||
it 'can be set to round_robin' do
|
||||
assignment_policy.update!(assignment_order: :round_robin)
|
||||
expect(assignment_policy.assignment_order).to eq('round_robin')
|
||||
expect(assignment_policy.round_robin?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user