Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea7e9dfaa2 | ||
|
|
c18452f6b6 | ||
|
|
c403f6872c | ||
|
|
70919d184d | ||
|
|
24257f9771 | ||
|
|
c3aab44b5f | ||
|
|
b811c27ab5 | ||
|
|
55f1690d9e | ||
|
|
2c75ccb004 | ||
|
|
d997734837 | ||
|
|
2c1a8e59f5 | ||
|
|
f6d87d0e6e | ||
|
|
56874c442c | ||
|
|
eb908d9c03 | ||
|
|
5ccae73f8a | ||
|
|
80e6a2a479 | ||
|
|
cf0975ad94 | ||
|
|
cb42be8e65 | ||
|
|
9cee8a1713 | ||
|
|
d925729444 | ||
|
|
ef7bf66476 |
@@ -5,6 +5,7 @@
|
||||
# #
|
||||
|
||||
name: Publish Chatwoot CE docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -12,23 +13,32 @@ on:
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
# pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
@@ -39,29 +49,97 @@ jobs:
|
||||
run: |
|
||||
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
|
||||
|
||||
- name: set docker tag
|
||||
- name: Set Docker Tags
|
||||
run: |
|
||||
# Replace forward slashes with hyphens in the ref name
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
echo "DOCKER_TAG=chatwoot/chatwoot:$SANITIZED_REF-ce" >> $GITHUB_ENV
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:latest-ce" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}-ce" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: replace docker tag if master
|
||||
if: github.ref_name == 'master'
|
||||
run: |
|
||||
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64, linux/arm64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||
tags: ${{ env.DOCKER_TAG }}
|
||||
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest-ce"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create -t $TAG \
|
||||
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest-ce"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
|
||||
fi
|
||||
|
||||
docker buildx imagetools inspect $TAG
|
||||
|
||||
@@ -120,4 +120,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
|
||||
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
|
||||
|
||||
|
||||
*Chatwoot* © 2017-2024, Chatwoot Inc - Released under the MIT License.
|
||||
*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License.
|
||||
|
||||
@@ -28,7 +28,7 @@ class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::B
|
||||
end
|
||||
|
||||
def object_scope
|
||||
scope.reporting_events.where(name: event_name, created_at: range)
|
||||
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
|
||||
@@ -1,58 +1,70 @@
|
||||
module Api::V2::Accounts::ReportsHelper
|
||||
def generate_agents_report
|
||||
reports = V2::Reports::AgentSummaryBuilder.new(
|
||||
account: Current.account,
|
||||
params: build_params(type: :agent)
|
||||
).build
|
||||
|
||||
Current.account.users.map do |agent|
|
||||
agent_report = report_builder({ type: :agent, id: agent.id }).summary
|
||||
[agent.name] + generate_readable_report_metrics(agent_report)
|
||||
report = reports.find { |r| r[:id] == agent.id }
|
||||
[agent.name] + generate_readable_report_metrics(report)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_inboxes_report
|
||||
reports = V2::Reports::InboxSummaryBuilder.new(
|
||||
account: Current.account,
|
||||
params: build_params(type: :inbox)
|
||||
).build
|
||||
|
||||
Current.account.inboxes.map do |inbox|
|
||||
inbox_report = generate_report({ type: :inbox, id: inbox.id })
|
||||
[inbox.name, inbox.channel&.name] + generate_readable_report_metrics(inbox_report)
|
||||
report = reports.find { |r| r[:id] == inbox.id }
|
||||
[inbox.name, inbox.channel&.name] + generate_readable_report_metrics(report)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_teams_report
|
||||
reports = V2::Reports::TeamSummaryBuilder.new(
|
||||
account: Current.account,
|
||||
params: build_params(type: :team)
|
||||
).build
|
||||
|
||||
Current.account.teams.map do |team|
|
||||
team_report = report_builder({ type: :team, id: team.id }).summary
|
||||
[team.name] + generate_readable_report_metrics(team_report)
|
||||
report = reports.find { |r| r[:id] == team.id }
|
||||
[team.name] + generate_readable_report_metrics(report)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_labels_report
|
||||
Current.account.labels.map do |label|
|
||||
label_report = generate_report({ type: :label, id: label.id })
|
||||
label_report = report_builder({ type: :label, id: label.id }).short_summary
|
||||
[label.title] + generate_readable_report_metrics(label_report)
|
||||
end
|
||||
end
|
||||
|
||||
def report_builder(report_params)
|
||||
V2::ReportBuilder.new(
|
||||
Current.account,
|
||||
report_params.merge(
|
||||
{
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
}
|
||||
)
|
||||
private
|
||||
|
||||
def build_params(base_params)
|
||||
base_params.merge(
|
||||
{
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def generate_report(report_params)
|
||||
report_builder(report_params).short_summary
|
||||
def report_builder(report_params)
|
||||
V2::ReportBuilder.new(Current.account, build_params(report_params))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_readable_report_metrics(report_metric)
|
||||
def generate_readable_report_metrics(report)
|
||||
[
|
||||
report_metric[:conversations_count],
|
||||
Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format,
|
||||
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format,
|
||||
Reports::TimeFormatPresenter.new(report_metric[:reply_time]).format,
|
||||
report_metric[:resolutions_count]
|
||||
report[:conversations_count],
|
||||
Reports::TimeFormatPresenter.new(report[:avg_first_response_time]).format,
|
||||
Reports::TimeFormatPresenter.new(report[:avg_resolution_time]).format,
|
||||
Reports::TimeFormatPresenter.new(report[:avg_reply_time]).format,
|
||||
report[:resolved_conversations_count]
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class SummaryReportsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
|
||||
}
|
||||
|
||||
getTeamReports({ since, until, businessHours } = {}) {
|
||||
return axios.get(`${this.url}/team`, {
|
||||
params: {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getAgentReports({ since, until, businessHours } = {}) {
|
||||
return axios.get(`${this.url}/agent`, {
|
||||
params: {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getInboxReports({ since, until, businessHours } = {}) {
|
||||
return axios.get(`${this.url}/inbox`, {
|
||||
params: {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new SummaryReportsAPI();
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { required, email, minLength } from '@vuelidate/validators';
|
||||
import { required, email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { splitName } from '@chatwoot/utils';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
@@ -74,7 +74,7 @@ const defaultState = {
|
||||
const state = reactive({ ...defaultState });
|
||||
|
||||
const validationRules = {
|
||||
firstName: { required, minLength: minLength(2) },
|
||||
firstName: { required },
|
||||
email: { email },
|
||||
};
|
||||
|
||||
|
||||
+1
@@ -59,6 +59,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
|
||||
"
|
||||
color="blue"
|
||||
:disabled="contactsFormRef?.isFormInvalid"
|
||||
:is-loading="isCreatingContact"
|
||||
@click="handleDialogConfirm"
|
||||
/>
|
||||
|
||||
+34
-2
@@ -2,6 +2,7 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
|
||||
|
||||
@@ -14,6 +15,8 @@ const props = defineProps({
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
const searchQuery = ref('');
|
||||
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes') || [];
|
||||
@@ -46,20 +49,49 @@ const processContactAttributes = (
|
||||
}, []);
|
||||
};
|
||||
|
||||
const sortAttributesOrder = computed(
|
||||
() =>
|
||||
uiSettings.value.conversation_elements_order_conversation_contact_panel ??
|
||||
[]
|
||||
);
|
||||
|
||||
const sortByUISettings = attributes => {
|
||||
// Get saved order from UI settings
|
||||
// Same as conversation panel contact attribute order
|
||||
const order = sortAttributesOrder.value;
|
||||
|
||||
// If no order defined, return original array
|
||||
if (!order?.length) return attributes;
|
||||
|
||||
const orderMap = new Map(order.map((key, index) => [key, index]));
|
||||
|
||||
// Sort attributes based on their position in saved order
|
||||
return [...attributes].sort((a, b) => {
|
||||
// Get positions, use Infinity if not found in order (pushes to end)
|
||||
const aPos = orderMap.get(a.attributeKey) ?? Infinity;
|
||||
const bPos = orderMap.get(b.attributeKey) ?? Infinity;
|
||||
return aPos - bPos;
|
||||
});
|
||||
};
|
||||
|
||||
const usedAttributes = computed(() => {
|
||||
return processContactAttributes(
|
||||
const attributes = processContactAttributes(
|
||||
contactAttributes.value,
|
||||
props.selectedContact?.customAttributes,
|
||||
(key, custom) => key in custom
|
||||
);
|
||||
|
||||
return sortByUISettings(attributes);
|
||||
});
|
||||
|
||||
const unusedAttributes = computed(() => {
|
||||
return processContactAttributes(
|
||||
const attributes = processContactAttributes(
|
||||
contactAttributes.value,
|
||||
props.selectedContact?.customAttributes,
|
||||
(key, custom) => !(key in custom)
|
||||
);
|
||||
|
||||
return sortByUISettings(attributes);
|
||||
});
|
||||
|
||||
const filteredUnusedAttributes = computed(() => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
@@ -8,6 +10,10 @@ defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
actionPerms: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -16,7 +22,7 @@ defineProps({
|
||||
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]"
|
||||
class="relative w-full max-w-[960px] mx-auto overflow-hidden h-full max-h-[448px]"
|
||||
>
|
||||
<div
|
||||
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
||||
@@ -39,7 +45,9 @@ defineProps({
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
<Policy :permissions="actionPerms">
|
||||
<slot name="actions" />
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
color: {
|
||||
type: String,
|
||||
default: 'slate',
|
||||
validator: value =>
|
||||
['blue', 'ruby', 'amber', 'slate', 'teal'].includes(value),
|
||||
},
|
||||
actionLabel: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const bannerClass = computed(() => {
|
||||
const classMap = {
|
||||
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
|
||||
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
|
||||
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
|
||||
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
|
||||
};
|
||||
|
||||
return classMap[props.color];
|
||||
});
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
const classMap = {
|
||||
slate: 'bg-n-slate-4 text-n-slate-11',
|
||||
amber: 'bg-n-amber-4 text-n-amber-11',
|
||||
teal: 'bg-n-teal-4 text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-4 text-n-ruby-11',
|
||||
blue: 'bg-n-blue-4 text-n-blue-11',
|
||||
};
|
||||
|
||||
return classMap[props.color];
|
||||
});
|
||||
|
||||
const triggerAction = () => {
|
||||
emit('action');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="text-sm rounded-xl flex items-center justify-between gap-2 border"
|
||||
:class="[
|
||||
bannerClass,
|
||||
{
|
||||
'py-2 px-3': !actionLabel,
|
||||
'pl-3 p-2': actionLabel,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
v-if="actionLabel"
|
||||
class="px-3 py-1 w-auto grid place-content-center rounded-lg"
|
||||
:class="buttonClass"
|
||||
@click="triggerAction"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
defineProps({
|
||||
const { featureFlag } = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
@@ -19,10 +23,26 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
buttonPolicy: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
featureFlag: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showPaginationFooter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -30,6 +50,11 @@ defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'close', 'update:currentPage']);
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const showPaywall = computed(() => {
|
||||
return !isCloudFeatureEnabled(featureFlag);
|
||||
});
|
||||
|
||||
const handleButtonClick = () => {
|
||||
emit('click');
|
||||
@@ -52,16 +77,19 @@ const handlePageChange = event => {
|
||||
<slot name="headerTitle" />
|
||||
</span>
|
||||
<div
|
||||
v-if="!showPaywall"
|
||||
v-on-clickaway="() => emit('close')"
|
||||
class="relative group/campaign-button"
|
||||
>
|
||||
<Button
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="group-hover/campaign-button:brightness-110"
|
||||
@click="handleButtonClick"
|
||||
/>
|
||||
<Policy :permissions="buttonPolicy">
|
||||
<Button
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="group-hover/campaign-button:brightness-110"
|
||||
@click="handleButtonClick"
|
||||
/>
|
||||
</Policy>
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,7 +97,21 @@ const handlePageChange = event => {
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
|
||||
<div class="w-full max-w-[960px] mx-auto py-4">
|
||||
<slot name="default" />
|
||||
<slot name="controls" />
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="showPaywall">
|
||||
<slot name="paywall" />
|
||||
</div>
|
||||
<div v-else-if="isEmpty">
|
||||
<slot name="emptyState" />
|
||||
</div>
|
||||
<slot v-else name="body" />
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
@@ -28,31 +29,41 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.OPTIONS.VIEW_CONNECTED_INBOXES'),
|
||||
value: 'viewConnectedInboxes',
|
||||
action: 'viewConnectedInboxes',
|
||||
icon: 'i-lucide-link',
|
||||
},
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.OPTIONS.EDIT_ASSISTANT'),
|
||||
value: 'edit',
|
||||
action: 'edit',
|
||||
icon: 'i-lucide-pencil-line',
|
||||
},
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.OPTIONS.DELETE_ASSISTANT'),
|
||||
value: 'delete',
|
||||
action: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
]);
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.OPTIONS.VIEW_CONNECTED_INBOXES'),
|
||||
value: 'viewConnectedInboxes',
|
||||
action: 'viewConnectedInboxes',
|
||||
icon: 'i-lucide-link',
|
||||
},
|
||||
];
|
||||
|
||||
if (checkPermissions(['administrator'])) {
|
||||
allOptions.push(
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.OPTIONS.EDIT_ASSISTANT'),
|
||||
value: 'edit',
|
||||
action: 'edit',
|
||||
icon: 'i-lucide-pencil-line',
|
||||
},
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.OPTIONS.DELETE_ASSISTANT'),
|
||||
value: 'delete',
|
||||
action: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return allOptions;
|
||||
});
|
||||
|
||||
const lastUpdatedAt = computed(() => dynamicTime(props.updatedAt));
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
@@ -32,25 +33,33 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
|
||||
value: 'viewRelatedQuestions',
|
||||
action: 'viewRelatedQuestions',
|
||||
icon: 'i-ph-tree-view-duotone',
|
||||
},
|
||||
{
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
|
||||
value: 'delete',
|
||||
action: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
]);
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
{
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
|
||||
value: 'viewRelatedQuestions',
|
||||
action: 'viewRelatedQuestions',
|
||||
icon: 'i-ph-tree-view-duotone',
|
||||
},
|
||||
];
|
||||
|
||||
if (checkPermissions(['administrator'])) {
|
||||
allOptions.push({
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
|
||||
value: 'delete',
|
||||
action: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
});
|
||||
}
|
||||
|
||||
return allOptions;
|
||||
});
|
||||
|
||||
const createdAt = computed(() => dynamicTime(props.createdAt));
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import { INBOX_TYPES, getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -76,8 +77,9 @@ const handleAction = ({ action, value }) => {
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
<Policy
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
:permissions="['administrator']"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
@@ -93,7 +95,7 @@ const handleAction = ({ action, value }) => {
|
||||
class="mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -107,8 +108,9 @@ const handleDocumentableClick = () => {
|
||||
{{ question }}
|
||||
</span>
|
||||
<div v-if="!compact" class="flex items-center gap-2">
|
||||
<div
|
||||
<Policy
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
:permissions="['administrator']"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
@@ -124,7 +126,7 @@ const handleDocumentableClick = () => {
|
||||
class="mt-1 ltr:right-0 rtl:right-0 top-full"
|
||||
@action="handleAssistantAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-n-slate-11 text-sm line-clamp-5">
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const isSuperAdmin = computed(() => {
|
||||
return currentUser.value.type === 'SuperAdmin';
|
||||
});
|
||||
const { accountId, isOnChatwootCloud } = useAccount();
|
||||
|
||||
const i18nKey = computed(() =>
|
||||
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
|
||||
);
|
||||
const openBilling = () => {
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full max-w-[960px] mx-auto h-full max-h-[448px] grid place-content-center"
|
||||
>
|
||||
<BasePaywallModal
|
||||
class="mx-auto"
|
||||
feature-prefix="CAPTAIN"
|
||||
:i18n-key="i18nKey"
|
||||
:is-super-admin="isSuperAdmin"
|
||||
:is-on-chatwoot-cloud="isOnChatwootCloud"
|
||||
@upgrade="openBilling"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
const { documentLimits, fetchLimits } = useCaptain();
|
||||
|
||||
const openBilling = () => {
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
};
|
||||
|
||||
const showBanner = computed(() => {
|
||||
if (!documentLimits.value) return false;
|
||||
|
||||
const { currentAvailable } = documentLimits.value;
|
||||
return currentAvailable === 0;
|
||||
});
|
||||
|
||||
onMounted(fetchLimits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-show="showBanner"
|
||||
color="amber"
|
||||
:action-label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
|
||||
@action="openBilling"
|
||||
>
|
||||
{{ $t('CAPTAIN.BANNER.DOCUMENTS') }}
|
||||
</Banner>
|
||||
</template>
|
||||
+1
@@ -15,6 +15,7 @@ const onClick = () => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.SUBTITLE')"
|
||||
:action-perms="['administrator']"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ const onClick = () => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.SUBTITLE')"
|
||||
:action-perms="['administrator']"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ const onClick = () => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.INBOXES.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.INBOXES.EMPTY_STATE.SUBTITLE')"
|
||||
:action-perms="['administrator']"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ const onClick = () => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
|
||||
:action-perms="['administrator']"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
const { responseLimits, fetchLimits } = useCaptain();
|
||||
|
||||
const openBilling = () => {
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
};
|
||||
|
||||
const showBanner = computed(() => {
|
||||
if (!responseLimits.value) return false;
|
||||
|
||||
const { consumed, totalCount } = responseLimits.value;
|
||||
if (!consumed || !totalCount) return false;
|
||||
|
||||
return consumed / totalCount > 0.8;
|
||||
});
|
||||
|
||||
onMounted(fetchLimits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-show="showBanner"
|
||||
color="amber"
|
||||
:action-label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
|
||||
@action="openBilling"
|
||||
>
|
||||
{{ $t('CAPTAIN.BANNER.RESPONSES') }}
|
||||
</Banner>
|
||||
</template>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import BaseBubble from './Base.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
@@ -10,6 +12,8 @@ import { downloadFile } from '@chatwoot/utils';
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
|
||||
const emit = defineEmits(['error']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
||||
|
||||
const attachment = computed(() => {
|
||||
@@ -31,7 +35,7 @@ const downloadAttachment = async () => {
|
||||
isDownloading.value = true;
|
||||
await downloadFile({ url: dataUrl, type: fileType, extension });
|
||||
} catch (error) {
|
||||
// error
|
||||
useAlert(t('GALLERY_VIEW.ERROR_DOWNLOADING'));
|
||||
} finally {
|
||||
isDownloading.value = false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getFileInfo } from '@chatwoot/utils';
|
||||
|
||||
import FileIcon from 'next/icon/FileIcon.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
@@ -14,17 +15,20 @@ const { attachment } = defineProps({
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const fileName = computed(() => {
|
||||
const url = attachment.dataUrl;
|
||||
if (url) {
|
||||
const filename = url.substring(url.lastIndexOf('/') + 1);
|
||||
return filename || t('CONVERSATION.UNKNOWN_FILE_TYPE');
|
||||
}
|
||||
return t('CONVERSATION.UNKNOWN_FILE_TYPE');
|
||||
const fileDetails = computed(() => {
|
||||
return getFileInfo(attachment?.dataUrl || '');
|
||||
});
|
||||
|
||||
const fileType = computed(() => {
|
||||
return fileName.value.split('.').pop();
|
||||
const displayFileName = computed(() => {
|
||||
const { base, type } = fileDetails.value;
|
||||
const truncatedName = (str, maxLength, hasExt) =>
|
||||
str.length > maxLength
|
||||
? `${str.substring(0, maxLength).trimEnd()}${hasExt ? '..' : '...'}`
|
||||
: str;
|
||||
|
||||
return type
|
||||
? `${truncatedName(base, 12, true)}.${type}`
|
||||
: truncatedName(base, 14, false);
|
||||
});
|
||||
|
||||
const textColorClass = computed(() => {
|
||||
@@ -47,21 +51,25 @@ const textColorClass = computed(() => {
|
||||
zip: 'dark:text-[#EDEEF0] text-[#2F265F]',
|
||||
};
|
||||
|
||||
return colorMap[fileType.value] || 'text-n-slate-12';
|
||||
return colorMap[fileDetails.value.type] || 'text-n-slate-12';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="h-9 bg-n-alpha-white gap-2 items-center flex px-2 rounded-lg border border-n-container"
|
||||
class="h-9 bg-n-alpha-white gap-2 overflow-hidden items-center flex px-2 rounded-lg border border-n-container"
|
||||
>
|
||||
<FileIcon class="flex-shrink-0" :file-type="fileType" />
|
||||
<span class="mr-1 max-w-32 truncate" :class="textColorClass">
|
||||
{{ fileName }}
|
||||
<FileIcon class="flex-shrink-0" :file-type="fileDetails.type" />
|
||||
<span
|
||||
class="flex-1 min-w-0 text-sm max-w-36"
|
||||
:title="fileDetails.name"
|
||||
:class="textColorClass"
|
||||
>
|
||||
{{ displayFileName }}
|
||||
</span>
|
||||
<a
|
||||
v-tooltip="t('CONVERSATION.DOWNLOAD')"
|
||||
class="flex-shrink-0 h-9 grid place-content-center cursor-pointer text-n-slate-11"
|
||||
class="flex-shrink-0 size-9 grid place-content-center cursor-pointer text-n-slate-11 hover:text-n-slate-12 transition-colors"
|
||||
:href="attachment.dataUrl"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
@@ -36,6 +37,18 @@ const toggleShortcutModalFn = show => {
|
||||
}
|
||||
};
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const showV4Routes = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.REPORT_V4
|
||||
);
|
||||
});
|
||||
|
||||
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
|
||||
|
||||
// We're using localStorage to store the expanded item in the sidebar
|
||||
@@ -77,6 +90,59 @@ const sortedInboxes = computed(() =>
|
||||
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
const newReportRoutes = [
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
label: t('SIDEBAR.REPORTS_AGENT'),
|
||||
to: accountScopedRoute('agent_reports_index'),
|
||||
activeOn: ['agent_reports_show'],
|
||||
},
|
||||
{
|
||||
name: 'Reports Label',
|
||||
label: t('SIDEBAR.REPORTS_LABEL'),
|
||||
to: accountScopedRoute('label_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Inbox',
|
||||
label: t('SIDEBAR.REPORTS_INBOX'),
|
||||
to: accountScopedRoute('inbox_reports_index'),
|
||||
activeOn: ['inbox_reports_show'],
|
||||
},
|
||||
{
|
||||
name: 'Reports Team',
|
||||
label: t('SIDEBAR.REPORTS_TEAM'),
|
||||
to: accountScopedRoute('team_reports_index'),
|
||||
activeOn: ['team_reports_show'],
|
||||
},
|
||||
];
|
||||
|
||||
const oldReportRoutes = [
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
label: t('SIDEBAR.REPORTS_AGENT'),
|
||||
to: accountScopedRoute('agent_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Label',
|
||||
label: t('SIDEBAR.REPORTS_LABEL'),
|
||||
to: accountScopedRoute('label_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Inbox',
|
||||
label: t('SIDEBAR.REPORTS_INBOX'),
|
||||
to: accountScopedRoute('inbox_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Team',
|
||||
label: t('SIDEBAR.REPORTS_TEAM'),
|
||||
to: accountScopedRoute('team_reports'),
|
||||
},
|
||||
];
|
||||
|
||||
const reportRoutes = computed(() =>
|
||||
showV4Routes.value ? newReportRoutes : oldReportRoutes
|
||||
);
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
@@ -85,6 +151,9 @@ const menuItems = computed(() => {
|
||||
icon: 'i-lucide-inbox',
|
||||
to: accountScopedRoute('inbox_view'),
|
||||
activeOn: ['inbox_view', 'inbox_view_conversation'],
|
||||
getterKeys: {
|
||||
badge: 'notifications/getHasUnreadNotifications',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Conversation',
|
||||
@@ -171,20 +240,24 @@ const menuItems = computed(() => {
|
||||
name: 'Captain',
|
||||
icon: 'i-woot-captain',
|
||||
label: t('SIDEBAR.CAPTAIN'),
|
||||
showOnlyOnCloud: true,
|
||||
children: [
|
||||
{
|
||||
name: 'Assistants',
|
||||
label: t('SIDEBAR.CAPTAIN_ASSISTANTS'),
|
||||
showOnlyOnCloud: true,
|
||||
to: accountScopedRoute('captain_assistants_index'),
|
||||
},
|
||||
{
|
||||
name: 'Documents',
|
||||
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
|
||||
showOnlyOnCloud: true,
|
||||
to: accountScopedRoute('captain_documents_index'),
|
||||
},
|
||||
{
|
||||
name: 'Responses',
|
||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||
showOnlyOnCloud: true,
|
||||
to: accountScopedRoute('captain_responses_index'),
|
||||
},
|
||||
],
|
||||
@@ -261,31 +334,12 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.REPORTS_CONVERSATION'),
|
||||
to: accountScopedRoute('conversation_reports'),
|
||||
},
|
||||
...reportRoutes.value,
|
||||
{
|
||||
name: 'Reports CSAT',
|
||||
label: t('SIDEBAR.CSAT'),
|
||||
to: accountScopedRoute('csat_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
label: t('SIDEBAR.REPORTS_AGENT'),
|
||||
to: accountScopedRoute('agent_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Label',
|
||||
label: t('SIDEBAR.REPORTS_LABEL'),
|
||||
to: accountScopedRoute('label_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Inbox',
|
||||
label: t('SIDEBAR.REPORTS_INBOX'),
|
||||
to: accountScopedRoute('inbox_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Team',
|
||||
label: t('SIDEBAR.REPORTS_TEAM'),
|
||||
to: accountScopedRoute('team_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports SLA',
|
||||
label: t('SIDEBAR.REPORTS_SLA'),
|
||||
@@ -455,6 +509,7 @@ const menuItems = computed(() => {
|
||||
name: 'Settings Billing',
|
||||
label: t('SIDEBAR.BILLING'),
|
||||
icon: 'i-lucide-credit-card',
|
||||
showOnlyOnCloud: true,
|
||||
to: accountScopedRoute('billing_settings_index'),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -19,6 +20,12 @@ const { accountId, currentAccount } = useAccount();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
const userAccounts = useMapGetter('getUserAccounts');
|
||||
|
||||
const showAccountSwitcher = computed(
|
||||
() => userAccounts.value.length > 1 && currentAccount.value.name
|
||||
);
|
||||
|
||||
const onChangeAccount = newId => {
|
||||
const accountUrl = `/app/accounts/${newId}/dashboard`;
|
||||
window.location.href = accountUrl;
|
||||
@@ -37,9 +44,14 @@ const emitNewAccount = () => {
|
||||
:data-account-id="accountId"
|
||||
aria-haspopup="listbox"
|
||||
aria-controls="account-options"
|
||||
class="flex items-center gap-2 justify-between w-full rounded-lg hover:bg-n-alpha-1 px-2"
|
||||
:class="{ 'bg-n-alpha-1': isOpen }"
|
||||
@click="toggle"
|
||||
class="flex items-center gap-2 justify-between w-full rounded-lg px-2"
|
||||
:class="[
|
||||
isOpen && 'bg-n-alpha-1',
|
||||
showAccountSwitcher
|
||||
? 'hover:bg-n-alpha-1 cursor-pointer'
|
||||
: 'cursor-default',
|
||||
]"
|
||||
@click="() => showAccountSwitcher && toggle()"
|
||||
>
|
||||
<span
|
||||
class="text-sm font-medium leading-5 text-n-slate-12 truncate"
|
||||
@@ -49,13 +61,14 @@ const emitNewAccount = () => {
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="showAccountSwitcher"
|
||||
aria-hidden="true"
|
||||
class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<DropdownBody class="min-w-80 z-50">
|
||||
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_WORKSPACE')">
|
||||
<DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50">
|
||||
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
|
||||
<DropdownItem
|
||||
v-for="account in currentUser.accounts"
|
||||
:id="`account-${account.id}`"
|
||||
|
||||
@@ -15,6 +15,7 @@ const props = defineProps({
|
||||
to: { type: Object, default: null },
|
||||
activeOn: { type: Array, default: () => [] },
|
||||
children: { type: Array, default: undefined },
|
||||
getterKeys: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -23,6 +24,7 @@ const {
|
||||
resolvePath,
|
||||
resolvePermissions,
|
||||
resolveFeatureFlag,
|
||||
isOnChatwootCloud,
|
||||
isAllowed,
|
||||
} = useSidebarContext();
|
||||
|
||||
@@ -41,6 +43,7 @@ const hasChildren = computed(
|
||||
const accessibleItems = computed(() => {
|
||||
if (!hasChildren.value) return [];
|
||||
return props.children.filter(child => {
|
||||
if (child.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
|
||||
// If a item has no link, it means it's just a subgroup header
|
||||
// So we don't need to check for permissions here, because there's nothing to
|
||||
// access here anyway
|
||||
@@ -141,6 +144,7 @@ onMounted(async () => {
|
||||
:name
|
||||
:label
|
||||
:to
|
||||
:getter-keys="getterKeys"
|
||||
:is-active="isActive"
|
||||
:has-active-child="hasActiveChild"
|
||||
:expandable="hasChildren"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
to: { type: [Object, String], default: '' },
|
||||
label: { type: String, default: '' },
|
||||
icon: { type: [String, Object], default: '' },
|
||||
@@ -9,9 +10,12 @@ defineProps({
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
isActive: { type: Boolean, default: false },
|
||||
hasActiveChild: { type: Boolean, default: false },
|
||||
getterKeys: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const showBadge = useMapGetter(props.getterKeys.badge);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,7 +32,13 @@ const emit = defineEmits(['toggle']);
|
||||
}"
|
||||
@click.stop="emit('toggle')"
|
||||
>
|
||||
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||
<div v-if="icon" class="relative flex items-center gap-2">
|
||||
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||
<span
|
||||
v-if="showBadge"
|
||||
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-medium leading-5 flex-grow">
|
||||
{{ label }}
|
||||
</span>
|
||||
|
||||
@@ -9,18 +9,28 @@ const props = defineProps({
|
||||
to: { type: [String, Object], required: true },
|
||||
icon: { type: [String, Object], default: null },
|
||||
active: { type: Boolean, default: false },
|
||||
showOnlyOnCloud: { type: Boolean, default: false },
|
||||
component: { type: Function, default: null },
|
||||
});
|
||||
|
||||
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
const { resolvePermissions, resolveFeatureFlag, isOnChatwootCloud } =
|
||||
useSidebarContext();
|
||||
|
||||
const allowedToShow = computed(() => {
|
||||
if (props.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const shouldRenderComponent = computed(() => {
|
||||
return typeof props.component === 'function' || isVNode(props.component);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<Policy
|
||||
v-if="allowedToShow"
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
||||
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
|
||||
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
|
||||
|
||||
import { useSidebarContext } from './provider';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
@@ -15,19 +14,17 @@ const props = defineProps({
|
||||
activeChild: { type: Object, default: undefined },
|
||||
});
|
||||
|
||||
const { isAllowed } = useSidebarContext();
|
||||
const { isAllowed, isOnChatwootCloud } = useSidebarContext();
|
||||
const scrollableContainer = ref(null);
|
||||
|
||||
const accessibleItems = computed(() =>
|
||||
props.children.filter(child => isAllowed(child.to))
|
||||
props.children.filter(child => {
|
||||
if (child.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
|
||||
return child.to && isAllowed(child.to);
|
||||
})
|
||||
);
|
||||
|
||||
const hasAccessibleItems = computed(() => {
|
||||
if (props.children.length === 0) {
|
||||
// cases like segment, folder and labels where users can create new items
|
||||
return true;
|
||||
}
|
||||
|
||||
return accessibleItems.value.length > 0;
|
||||
});
|
||||
|
||||
@@ -52,7 +49,7 @@ useEventListener(scrollableContainer, 'scroll', () => {
|
||||
:icon
|
||||
class="my-1"
|
||||
/>
|
||||
<ul class="m-0 list-none reset-base relative group">
|
||||
<ul v-if="children.length" class="m-0 list-none reset-base relative group">
|
||||
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
|
||||
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
|
||||
<div
|
||||
@@ -61,16 +58,13 @@ useEventListener(scrollableContainer, 'scroll', () => {
|
||||
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
|
||||
}"
|
||||
>
|
||||
<template v-if="children.length">
|
||||
<SidebarGroupLeaf
|
||||
v-for="child in children"
|
||||
v-show="isExpanded || activeChild?.name === child.name"
|
||||
v-bind="child"
|
||||
:key="child.name"
|
||||
:active="activeChild?.name === child.name"
|
||||
/>
|
||||
</template>
|
||||
<SidebarGroupEmptyLeaf v-else v-show="isExpanded" class="ml-3 rtl:mr-3" />
|
||||
<SidebarGroupLeaf
|
||||
v-for="child in children"
|
||||
v-show="isExpanded || activeChild?.name === child.name"
|
||||
v-bind="child"
|
||||
:key="child.name"
|
||||
:active="activeChild?.name === child.name"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isScrollable && isExpanded"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { inject, provide } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
@@ -11,6 +12,8 @@ export function useSidebarContext() {
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
|
||||
const { checkFeatureAllowed, checkPermissions } = usePolicy();
|
||||
|
||||
const resolvePath = to => {
|
||||
@@ -41,6 +44,7 @@ export function useSidebarContext() {
|
||||
resolvePermissions,
|
||||
resolveFeatureFlag,
|
||||
isAllowed,
|
||||
isOnChatwootCloud,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const reports = accountId => ({
|
||||
'agent_reports',
|
||||
'label_reports',
|
||||
'inbox_reports',
|
||||
'inbox_reports_show',
|
||||
'team_reports',
|
||||
'sla_reports',
|
||||
],
|
||||
|
||||
@@ -40,7 +40,7 @@ const headerClass = computed(() =>
|
||||
:style="{
|
||||
width: `${header.getSize()}px`,
|
||||
}"
|
||||
class="text-left py-3 px-5 font-normal text-sm"
|
||||
class="text-left py-3 px-5 font-medium text-sm text-n-slate-12"
|
||||
:class="headerClass"
|
||||
@click="header.column.getCanSort() && header.column.toggleSorting()"
|
||||
>
|
||||
|
||||
@@ -37,8 +37,10 @@ const buttonStyleClass = props.compact
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-left"
|
||||
class="size-5 ltr:-ml-1 rtl:-mr-1"
|
||||
:class="props.compact ? 'text-n-slate-11' : 'text-n-blue-text'"
|
||||
class="ltr:-ml-1 rtl:-mr-1"
|
||||
:class="
|
||||
props.compact ? 'text-n-slate-11 size-4' : 'text-n-blue-text size-5'
|
||||
"
|
||||
/>
|
||||
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
@@ -22,6 +25,8 @@ const props = defineProps({
|
||||
const emit = defineEmits(['close']);
|
||||
const show = defineModel('show', { type: Boolean, default: false });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const ALLOWED_FILE_TYPES = {
|
||||
@@ -129,7 +134,7 @@ const onClickDownload = async () => {
|
||||
isDownloading.value = true;
|
||||
await downloadFile({ url, type, extension });
|
||||
} catch (error) {
|
||||
// error
|
||||
useAlert(t('GALLERY_VIEW.ERROR_DOWNLOADING'));
|
||||
} finally {
|
||||
isDownloading.value = false;
|
||||
}
|
||||
|
||||
@@ -13,10 +13,14 @@ export function useAccount() {
|
||||
*/
|
||||
const route = useRoute();
|
||||
const getAccountFn = useMapGetter('accounts/getAccount');
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const accountId = computed(() => {
|
||||
return Number(route.params.accountId);
|
||||
});
|
||||
|
||||
const currentAccount = computed(() => getAccountFn.value(accountId.value));
|
||||
|
||||
/**
|
||||
@@ -28,6 +32,10 @@ export function useAccount() {
|
||||
return `/app/accounts/${accountId.value}/${url}`;
|
||||
};
|
||||
|
||||
const isCloudFeatureEnabled = feature => {
|
||||
return isFeatureEnabledonAccount.value(currentAccount.value.id, feature);
|
||||
};
|
||||
|
||||
const accountScopedRoute = (name, params, query) => {
|
||||
return {
|
||||
name,
|
||||
@@ -42,5 +50,7 @@ export function useAccount() {
|
||||
currentAccount,
|
||||
accountScopedUrl,
|
||||
accountScopedRoute,
|
||||
isCloudFeatureEnabled,
|
||||
isOnChatwootCloud,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
export function useCaptain() {
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled, currentAccount } = useAccount();
|
||||
|
||||
const captainEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
|
||||
});
|
||||
|
||||
const captainLimits = computed(() => {
|
||||
return currentAccount.value?.limits?.captain;
|
||||
});
|
||||
|
||||
const documentLimits = computed(() => {
|
||||
if (captainLimits.value?.documents) {
|
||||
return useCamelCase(captainLimits.value.documents);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const responseLimits = computed(() => {
|
||||
if (captainLimits.value?.responses) {
|
||||
return useCamelCase(captainLimits.value.responses);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const fetchLimits = () => {
|
||||
store.dispatch('accounts/limits');
|
||||
};
|
||||
|
||||
return {
|
||||
captainEnabled,
|
||||
captainLimits,
|
||||
documentLimits,
|
||||
responseLimits,
|
||||
fetchLimits,
|
||||
};
|
||||
}
|
||||
@@ -33,4 +33,5 @@ export const FEATURE_FLAGS = {
|
||||
CAPTAIN: 'captain_integration',
|
||||
CUSTOM_ROLES: 'custom_roles',
|
||||
CHATWOOT_V4: 'chatwoot_v4',
|
||||
REPORT_V4: 'report_v4',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CONVERSATION_PERMISSIONS,
|
||||
} from 'dashboard/constants/permissions';
|
||||
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
class AudioNotificationStore {
|
||||
constructor(store) {
|
||||
@@ -18,6 +19,15 @@ class AudioNotificationStore {
|
||||
return mineConversation.some(conv => conv.unread_count > 0);
|
||||
};
|
||||
|
||||
isMessageFromPendingConversation = (message = {}) => {
|
||||
const { conversation_id: conversationId } = message || {};
|
||||
if (!conversationId) return false;
|
||||
|
||||
const activeConversation =
|
||||
this.store.getters.getConversationById(conversationId);
|
||||
return activeConversation?.status === wootConstants.STATUS_TYPE.PENDING;
|
||||
};
|
||||
|
||||
isMessageFromCurrentConversation = message => {
|
||||
return this.store.getters.getSelectedChat?.id === message.conversation_id;
|
||||
};
|
||||
|
||||
@@ -172,6 +172,12 @@ export class DashboardAudioNotificationHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the conversation status is pending, then dismiss the alert
|
||||
// This case is common for all audio event types
|
||||
if (this.store.isMessageFromPendingConversation(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the message is sent by the current user then dismiss the alert
|
||||
if (isMessageFromCurrentUser(message, this.currentUser.id)) {
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
CONVERSATION_PERMISSIONS,
|
||||
} from 'dashboard/constants/permissions';
|
||||
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
vi.mock('dashboard/helper/permissionsHelper', () => ({
|
||||
getUserPermissions: vi.fn(),
|
||||
}));
|
||||
@@ -18,6 +20,7 @@ describe('AudioNotificationStore', () => {
|
||||
getMineChats: vi.fn(),
|
||||
getSelectedChat: null,
|
||||
getCurrentAccountId: 1,
|
||||
getConversationById: vi.fn(),
|
||||
},
|
||||
};
|
||||
audioNotificationStore = new AudioNotificationStore(store);
|
||||
@@ -59,6 +62,63 @@ describe('AudioNotificationStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMessageFromPendingConversation', () => {
|
||||
it('should return true when conversation status is pending', () => {
|
||||
store.getters.getConversationById.mockReturnValue({
|
||||
id: 123,
|
||||
status: wootConstants.STATUS_TYPE.PENDING,
|
||||
});
|
||||
const message = { conversation_id: 123 };
|
||||
|
||||
expect(
|
||||
audioNotificationStore.isMessageFromPendingConversation(message)
|
||||
).toBe(true);
|
||||
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
|
||||
});
|
||||
|
||||
it('should return false when conversation status is not pending', () => {
|
||||
store.getters.getConversationById.mockReturnValue({
|
||||
id: 123,
|
||||
status: wootConstants.STATUS_TYPE.OPEN,
|
||||
});
|
||||
const message = { conversation_id: 123 };
|
||||
|
||||
expect(
|
||||
audioNotificationStore.isMessageFromPendingConversation(message)
|
||||
).toBe(false);
|
||||
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
|
||||
});
|
||||
|
||||
it('should return false when conversation is not found', () => {
|
||||
store.getters.getConversationById.mockReturnValue(null);
|
||||
const message = { conversation_id: 123 };
|
||||
|
||||
expect(
|
||||
audioNotificationStore.isMessageFromPendingConversation(message)
|
||||
).toBe(false);
|
||||
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
|
||||
});
|
||||
|
||||
it('should return false when message has no conversation_id', () => {
|
||||
const message = {};
|
||||
|
||||
expect(
|
||||
audioNotificationStore.isMessageFromPendingConversation(message)
|
||||
).toBe(false);
|
||||
expect(store.getters.getConversationById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when message is null or undefined', () => {
|
||||
expect(
|
||||
audioNotificationStore.isMessageFromPendingConversation(null)
|
||||
).toBe(false);
|
||||
expect(
|
||||
audioNotificationStore.isMessageFromPendingConversation(undefined)
|
||||
).toBe(false);
|
||||
expect(store.getters.getConversationById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMessageFromCurrentConversation', () => {
|
||||
it('should return true when message is from selected chat', () => {
|
||||
store.getters.getSelectedChat = { id: 6179 };
|
||||
|
||||
@@ -29,6 +29,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'notification.updated': this.onNotificationUpdated,
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'team.changed': this.onTeamChanged,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
};
|
||||
}
|
||||
@@ -114,6 +115,11 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.fetchConversationStats();
|
||||
};
|
||||
|
||||
onTeamChanged = data => {
|
||||
this.app.$store.dispatch('updateConversation', data);
|
||||
this.fetchConversationStats();
|
||||
};
|
||||
|
||||
onTypingOn = ({ conversation, user }) => {
|
||||
const conversationId = conversation.id;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ const FEATURE_HELP_URLS = {
|
||||
sla: 'https://chwt.app/hc/sla',
|
||||
team_management: 'https://chwt.app/hc/teams',
|
||||
webhook: 'https://chwt.app/hc/webhooks',
|
||||
billing: 'https://chwt.app/pricing',
|
||||
};
|
||||
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
|
||||
@@ -356,5 +356,8 @@
|
||||
},
|
||||
"COPILOT": {
|
||||
"TRY_THESE_PROMPTS": "Try these prompts"
|
||||
},
|
||||
"GALLERY_VIEW": {
|
||||
"ERROR_DOWNLOADING": "Unable to download attachment. Please try again"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +309,22 @@
|
||||
"USE": "Use this",
|
||||
"RESET": "Reset"
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to use Captain AI",
|
||||
"AVAILABLE_ON": "Captain is not available on the free plan.",
|
||||
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
},
|
||||
"ENTERPRISE_PAYWALL": {
|
||||
"AVAILABLE_ON": "Captain AI feature is only available in a paid plan.",
|
||||
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
|
||||
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||
},
|
||||
"BANNER": {
|
||||
"RESPONSES": "You've used over 80% of your response limit. To continue using Captain AI, please upgrade.",
|
||||
"DOCUMENTS": "Document limit reached. Upgrade to continue using Captain AI."
|
||||
},
|
||||
"FORM": {
|
||||
"CANCEL": "Cancel",
|
||||
"CREATE": "Create",
|
||||
@@ -364,7 +380,7 @@
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No assistants available",
|
||||
"SUBTITLE": "Create an assistant to provide quick and accurate responses to your users. It can learn from your help articles and past conversations. Click the button below to get started."
|
||||
"SUBTITLE": "Create an assistant to provide quick and accurate responses to your users. It can learn from your help articles and past conversations."
|
||||
}
|
||||
},
|
||||
"DOCUMENTS": {
|
||||
@@ -406,13 +422,13 @@
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No documents available",
|
||||
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant. Click the button below to get started."
|
||||
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant."
|
||||
}
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
"DOCUMENTABLE" : {
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
},
|
||||
"DELETE": {
|
||||
@@ -422,7 +438,7 @@
|
||||
"SUCCESS_MESSAGE": "FAQ deleted successfully",
|
||||
"ERROR_MESSAGE": "There was an error deleting the FAQ, please try again."
|
||||
},
|
||||
"FILTER" :{
|
||||
"FILTER": {
|
||||
"ASSISTANT": "Assistant: {selected}",
|
||||
"STATUS": "Status: {selected}",
|
||||
"ALL_ASSISTANTS": "All"
|
||||
@@ -470,7 +486,7 @@
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No FAQs Found",
|
||||
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually. Click the button below to create your first FAQ."
|
||||
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually."
|
||||
}
|
||||
},
|
||||
"INBOXES": {
|
||||
@@ -501,7 +517,7 @@
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No Connected Inboxes",
|
||||
"SUBTITLE": "Connecting an inbox allows the assistant to handle initial questions from your customers before transferring them to you. Click the button below to set it up now."
|
||||
"SUBTITLE": "Connecting an inbox allows the assistant to handle initial questions from your customers before transferring them to you."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
},
|
||||
"AGENT_REPORTS": {
|
||||
"HEADER": "Agents Overview",
|
||||
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent’s name to learn more.",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
|
||||
@@ -258,6 +259,7 @@
|
||||
},
|
||||
"INBOX_REPORTS": {
|
||||
"HEADER": "Inbox Overview",
|
||||
"DESCRIPTION": "Quickly view your inbox performance with key metrics like conversations, response times, resolution times, and resolved cases—all in one place. Click an inbox name for more details.",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
|
||||
@@ -325,6 +327,7 @@
|
||||
},
|
||||
"TEAM_REPORTS": {
|
||||
"HEADER": "Team Overview",
|
||||
"DESCRIPTION": "Get a snapshot of your team’s performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_TEAM_REPORTS": "Download team reports",
|
||||
@@ -538,5 +541,15 @@
|
||||
},
|
||||
"VIEW_DETAILS": "View Details"
|
||||
}
|
||||
},
|
||||
"SUMMARY_REPORTS": {
|
||||
"INBOX": "Inbox",
|
||||
"AGENT": "Agent",
|
||||
"TEAM": "Team",
|
||||
"AVG_RESOLUTION_TIME": "Avg. Resolution Time",
|
||||
"AVG_FIRST_RESPONSE_TIME": "Avg. First Response Time",
|
||||
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",
|
||||
"RESOLUTION_COUNT": "Resolution Count",
|
||||
"CONVERSATIONS": "No. of conversations"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
"SIDEBAR_ITEMS": {
|
||||
"CHANGE_AVAILABILITY_STATUS": "Change",
|
||||
"CHANGE_ACCOUNTS": "Switch account",
|
||||
"SWITCH_WORKSPACE": "Switch workspace",
|
||||
"SWITCH_ACCOUNT": "Switch account",
|
||||
"CONTACT_SUPPORT": "Contact support",
|
||||
"SELECTOR_SUBTITLE": "Select an account from the following list",
|
||||
"PROFILE_SETTINGS": "Profile settings",
|
||||
@@ -265,7 +265,7 @@
|
||||
"CAPTAIN": "Captain",
|
||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||
"CAPTAIN_DOCUMENTS": "Documents",
|
||||
"CAPTAIN_RESPONSES" : "FAQs",
|
||||
"CAPTAIN_RESPONSES": "FAQs",
|
||||
"HOME": "Home",
|
||||
"AGENTS": "Agents",
|
||||
"AGENT_BOTS": "Bots",
|
||||
@@ -327,15 +327,27 @@
|
||||
},
|
||||
"BILLING_SETTINGS": {
|
||||
"TITLE": "Billing",
|
||||
"DESCRIPTION": "Manage your subscription here, upgrade your plan and get more for your team.",
|
||||
"CURRENT_PLAN": {
|
||||
"TITLE": "Current Plan",
|
||||
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses"
|
||||
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
|
||||
"SEAT_COUNT": "Number of seats",
|
||||
"RENEWS_ON": "Renews on"
|
||||
},
|
||||
"VIEW_PRICING": "View Pricing",
|
||||
"MANAGE_SUBSCRIPTION": {
|
||||
"TITLE": "Manage your subscription",
|
||||
"DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.",
|
||||
"BUTTON_TXT": "Go to the billing portal"
|
||||
},
|
||||
"CAPTAIN": {
|
||||
"TITLE": "Captain",
|
||||
"DESCRIPTION": "Manage usage and credits for Captain AI.",
|
||||
"BUTTON_TXT": "Buy more credits",
|
||||
"DOCUMENTS": "Documents",
|
||||
"RESPONSES": "Responses",
|
||||
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
|
||||
},
|
||||
"CHAT_WITH_US": {
|
||||
"TITLE": "Need help?",
|
||||
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
|
||||
import AssistantPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
@@ -73,29 +75,37 @@ onMounted(() => store.dispatch('captainAssistants/get'));
|
||||
<PageLayout
|
||||
:header-title="$t('CAPTAIN.ASSISTANTS.HEADER')"
|
||||
:button-label="$t('CAPTAIN.ASSISTANTS.ADD_NEW')"
|
||||
:button-policy="['administrator']"
|
||||
:show-pagination-footer="false"
|
||||
:is-fetching="isFetching"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:is-empty="!assistants.length"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="assistants.length" class="flex flex-col gap-4">
|
||||
<AssistantCard
|
||||
v-for="assistant in assistants"
|
||||
:id="assistant.id"
|
||||
:key="assistant.id"
|
||||
:name="assistant.name"
|
||||
:description="assistant.description"
|
||||
:updated-at="assistant.updated_at || assistant.created_at"
|
||||
:created-at="assistant.created_at"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
<template #emptyState>
|
||||
<AssistantPageEmptyState @click="handleCreate" />
|
||||
</template>
|
||||
|
||||
<AssistantPageEmptyState v-else @click="handleCreate" />
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<AssistantCard
|
||||
v-for="assistant in assistants"
|
||||
:id="assistant.id"
|
||||
:key="assistant.id"
|
||||
:name="assistant.name"
|
||||
:description="assistant.description"
|
||||
:updated-at="assistant.updated_at || assistant.created_at"
|
||||
:created-at="assistant.created_at"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DeleteDialog
|
||||
v-if="selectedAssistant"
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
useStoreGetters,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import BackButton from 'dashboard/components/widgets/BackButton.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ConnectInboxDialog from 'dashboard/components-next/captain/pageComponents/inbox/ConnectInboxDialog.vue';
|
||||
import InboxCard from 'dashboard/components-next/captain/assistant/InboxCard.vue';
|
||||
import InboxPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/InboxPageEmptyState.vue';
|
||||
@@ -67,19 +67,16 @@ onMounted(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isFetchingAssistant"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<PageLayout
|
||||
v-else
|
||||
:button-label="$t('CAPTAIN.INBOXES.ADD_NEW')"
|
||||
:button-policy="['administrator']"
|
||||
:is-fetching="isFetchingAssistant || isFetching"
|
||||
:is-empty="!captainInboxes.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:show-pagination-footer="false"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<template #headerTitle>
|
||||
<template v-if="!isFetchingAssistant" #headerTitle>
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<BackButton compact />
|
||||
<span class="flex items-center gap-1 text-lg">
|
||||
@@ -89,23 +86,22 @@ onMounted(() =>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="captainInboxes.length" class="flex flex-col gap-4">
|
||||
<InboxCard
|
||||
v-for="captainInbox in captainInboxes"
|
||||
:id="captainInbox.id"
|
||||
:key="captainInbox.id"
|
||||
:inbox="captainInbox"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InboxPageEmptyState v-else @click="handleCreate" />
|
||||
<template #emptyState>
|
||||
<InboxPageEmptyState @click="handleCreate" />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<InboxCard
|
||||
v-for="captainInbox in captainInboxes"
|
||||
:id="captainInbox.id"
|
||||
:key="captainInbox.id"
|
||||
:inbox="captainInbox"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DeleteDialog
|
||||
v-if="selectedInbox"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
// import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import AssistantIndex from './assistants/Index.vue';
|
||||
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
|
||||
@@ -11,7 +11,6 @@ export const routes = [
|
||||
component: AssistantIndex,
|
||||
name: 'captain_assistants_index',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
},
|
||||
@@ -22,7 +21,6 @@ export const routes = [
|
||||
component: AssistantInboxesIndex,
|
||||
name: 'captain_assistants_inboxes_index',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
},
|
||||
@@ -31,7 +29,6 @@ export const routes = [
|
||||
component: DocumentsIndex,
|
||||
name: 'captain_documents_index',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
},
|
||||
@@ -40,7 +37,6 @@ export const routes = [
|
||||
component: ResponsesIndex,
|
||||
name: 'captain_responses_index',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
|
||||
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
|
||||
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
|
||||
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
@@ -103,38 +105,49 @@ onMounted(() => {
|
||||
<PageLayout
|
||||
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
|
||||
:button-label="$t('CAPTAIN.DOCUMENTS.ADD_NEW')"
|
||||
:button-policy="['administrator']"
|
||||
:total-count="documentsMeta.totalCount"
|
||||
:current-page="documentsMeta.page"
|
||||
:show-pagination-footer="!isFetching && !!documents.length"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!documents.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreateDocument"
|
||||
>
|
||||
<div v-if="shouldShowAssistantSelector" class="mb-4 -mt-3 flex gap-3">
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="documents.length" class="flex flex-col gap-4">
|
||||
<DocumentCard
|
||||
v-for="doc in documents"
|
||||
:id="doc.id"
|
||||
:key="doc.id"
|
||||
:name="doc.name || doc.external_link"
|
||||
:external-link="doc.external_link"
|
||||
:assistant="doc.assistant"
|
||||
:created-at="doc.created_at"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
<template #emptyState>
|
||||
<DocumentPageEmptyState @click="handleCreateDocument" />
|
||||
</template>
|
||||
|
||||
<DocumentPageEmptyState v-else @click="handleCreateDocument" />
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #controls>
|
||||
<div v-if="shouldShowAssistantSelector" class="mb-4 -mt-3 flex gap-3">
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<DocumentCard
|
||||
v-for="doc in documents"
|
||||
:id="doc.id"
|
||||
:key="doc.id"
|
||||
:name="doc.name || doc.external_link"
|
||||
:external-link="doc.external_link"
|
||||
:assistant="doc.assistant"
|
||||
:created-at="doc.created_at"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<RelatedResponses
|
||||
v-if="showRelatedResponses"
|
||||
|
||||
@@ -5,16 +5,18 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
|
||||
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
@@ -156,61 +158,71 @@ onMounted(() => {
|
||||
<PageLayout
|
||||
:total-count="responseMeta.totalCount"
|
||||
:current-page="responseMeta.page"
|
||||
:button-policy="['administrator']"
|
||||
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
|
||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!responses.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:show-pagination-footer="!isFetching && !!responses.length"
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<div v-if="shouldShowDropdown" class="mb-4 -mt-3 flex gap-3">
|
||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
||||
<Button
|
||||
:label="selectedStatusLabel"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isStatusFilterOpen = !isStatusFilterOpen"
|
||||
<template #emptyState>
|
||||
<ResponsePageEmptyState @click="handleCreate" />
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #controls>
|
||||
<div v-if="shouldShowDropdown" class="mb-4 -mt-3 flex gap-3">
|
||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
||||
<Button
|
||||
:label="selectedStatusLabel"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isStatusFilterOpen = !isStatusFilterOpen"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isStatusFilterOpen"
|
||||
:menu-items="statusOptions"
|
||||
class="mt-2"
|
||||
@action="handleStatusFilterChange"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isStatusFilterOpen"
|
||||
:menu-items="statusOptions"
|
||||
class="mt-2"
|
||||
@action="handleStatusFilterChange"
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ResponseCard
|
||||
v-for="response in responses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
:answer="response.answer"
|
||||
:assistant="response.assistant"
|
||||
:documentable="response.documentable"
|
||||
:status="response.status"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="responses.length" class="flex flex-col gap-4">
|
||||
<ResponseCard
|
||||
v-for="response in responses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
:answer="response.answer"
|
||||
:assistant="response.assistant"
|
||||
:documentable="response.documentable"
|
||||
:status="response.status"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ResponsePageEmptyState v-else @click="handleCreate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DeleteDialog
|
||||
v-if="selectedResponse"
|
||||
|
||||
@@ -47,63 +47,68 @@ const staticElements = computed(() =>
|
||||
{
|
||||
content: initiatedAt,
|
||||
title: 'CONTACT_PANEL.INITIATED_AT',
|
||||
key: 'static-initiated-at',
|
||||
type: 'static_attribute',
|
||||
},
|
||||
{
|
||||
content: browserLanguage,
|
||||
title: 'CONTACT_PANEL.BROWSER_LANGUAGE',
|
||||
key: 'static-browser-language',
|
||||
type: 'static_attribute',
|
||||
},
|
||||
{
|
||||
content: referer,
|
||||
title: 'CONTACT_PANEL.INITIATED_FROM',
|
||||
type: 'link',
|
||||
key: 'static-referer',
|
||||
type: 'static_attribute',
|
||||
},
|
||||
{
|
||||
content: browserName,
|
||||
title: 'CONTACT_PANEL.BROWSER',
|
||||
key: 'static-browser',
|
||||
type: 'static_attribute',
|
||||
},
|
||||
{
|
||||
content: platformName,
|
||||
title: 'CONTACT_PANEL.OS',
|
||||
key: 'static-platform',
|
||||
type: 'static_attribute',
|
||||
},
|
||||
{
|
||||
content: createdAtIp,
|
||||
title: 'CONTACT_PANEL.IP_ADDRESS',
|
||||
key: 'static-ip-address',
|
||||
type: 'static_attribute',
|
||||
},
|
||||
].filter(attribute => !!attribute.content.value)
|
||||
);
|
||||
|
||||
const evenClass = [
|
||||
'[&>*:nth-child(odd)]:!bg-white [&>*:nth-child(even)]:!bg-slate-25',
|
||||
'dark:[&>*:nth-child(odd)]:!bg-slate-900 dark:[&>*:nth-child(even)]:!bg-slate-800/50',
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="conversation--details">
|
||||
<div :class="evenClass">
|
||||
<ContactDetailsItem
|
||||
v-for="element in staticElements"
|
||||
:key="element.title"
|
||||
:title="$t(element.title)"
|
||||
:value="element.content.value"
|
||||
class="border-b border-solid border-slate-50 dark:border-slate-700/50"
|
||||
>
|
||||
<a
|
||||
v-if="element.type === 'link'"
|
||||
:href="referer"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
class="text-woot-400 dark:text-woot-600"
|
||||
>
|
||||
{{ referer }}
|
||||
</a>
|
||||
</ContactDetailsItem>
|
||||
</div>
|
||||
<CustomAttributes
|
||||
:start-at="staticElements.length % 2 === 0 ? 'even' : 'odd'"
|
||||
:static-elements="staticElements"
|
||||
attribute-class="conversation--attribute"
|
||||
attribute-from="conversation_panel"
|
||||
attribute-type="conversation_attribute"
|
||||
/>
|
||||
>
|
||||
<template #staticItem="{ element }">
|
||||
<ContactDetailsItem
|
||||
:key="element.title"
|
||||
:title="$t(element.title)"
|
||||
:value="element.content.value"
|
||||
>
|
||||
<a
|
||||
v-if="element.key === 'static-referer'"
|
||||
:href="element.content.value"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
class="text-n-brand"
|
||||
>
|
||||
{{ element.content.value }}
|
||||
</a>
|
||||
</ContactDetailsItem>
|
||||
</template>
|
||||
</CustomAttributes>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+167
-53
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
@@ -23,10 +24,11 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
startAt: {
|
||||
type: String,
|
||||
default: 'even',
|
||||
validator: value => value === 'even' || value === 'odd',
|
||||
// Combine static elements with custom attributes components
|
||||
// To allow for custom ordering
|
||||
staticElements: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,6 +38,8 @@ const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const dragging = ref(false);
|
||||
|
||||
const [showAllAttributes, toggleShowAllAttributes] = useToggle(false);
|
||||
|
||||
const currentChat = computed(() => getters.getSelectedChat.value);
|
||||
@@ -68,7 +72,7 @@ const toggleButtonText = computed(() =>
|
||||
: t('CUSTOM_ATTRIBUTES.SHOW_LESS')
|
||||
);
|
||||
|
||||
const filteredAttributes = computed(() =>
|
||||
const filteredCustomAttributes = computed(() =>
|
||||
attributes.value.map(attribute => {
|
||||
// Check if the attribute key exists in customAttributes
|
||||
const hasValue = Object.hasOwnProperty.call(
|
||||
@@ -80,6 +84,8 @@ const filteredAttributes = computed(() =>
|
||||
|
||||
return {
|
||||
...attribute,
|
||||
type: 'custom_attribute',
|
||||
key: attribute.attribute_key,
|
||||
// Set value from customAttributes if it exists, otherwise use default value
|
||||
value: hasValue
|
||||
? customAttributes.value[attribute.attribute_key]
|
||||
@@ -88,27 +94,112 @@ const filteredAttributes = computed(() =>
|
||||
})
|
||||
);
|
||||
|
||||
const displayedAttributes = computed(() => {
|
||||
// Show only the first 5 attributes or all depending on showAllAttributes
|
||||
if (showAllAttributes.value || filteredAttributes.value.length <= 5) {
|
||||
return filteredAttributes.value;
|
||||
}
|
||||
return filteredAttributes.value.slice(0, 5);
|
||||
});
|
||||
|
||||
const showMoreUISettingsKey = computed(
|
||||
() => `show_all_attributes_${props.attributeFrom}`
|
||||
// Order key name for UI settings
|
||||
const orderKey = computed(
|
||||
() => `conversation_elements_order_${props.attributeFrom}`
|
||||
);
|
||||
|
||||
const combinedElements = computed(() => {
|
||||
// Get saved order from UI settings
|
||||
const savedOrder = uiSettings.value[orderKey.value] ?? [];
|
||||
const allElements = [
|
||||
...props.staticElements,
|
||||
...filteredCustomAttributes.value,
|
||||
];
|
||||
|
||||
// If no saved order exists, return in default order
|
||||
if (!savedOrder.length) return allElements;
|
||||
|
||||
return allElements.sort((a, b) => {
|
||||
// Find positions of elements in saved order
|
||||
const aPosition = savedOrder.indexOf(a.key);
|
||||
const bPosition = savedOrder.indexOf(b.key);
|
||||
|
||||
// Handle cases where elements are not in saved order:
|
||||
// - New elements (not in saved order) go to the end
|
||||
// - If both elements are new, maintain their relative order
|
||||
if (aPosition === -1 && bPosition === -1) return 0;
|
||||
if (aPosition === -1) return 1;
|
||||
if (bPosition === -1) return -1;
|
||||
|
||||
return aPosition - bPosition;
|
||||
});
|
||||
});
|
||||
|
||||
const displayedElements = computed(() => {
|
||||
if (showAllAttributes.value || combinedElements.value.length <= 5) {
|
||||
return combinedElements.value;
|
||||
}
|
||||
|
||||
// Show first 5 elements in the order they appear
|
||||
return combinedElements.value.slice(0, 5);
|
||||
});
|
||||
|
||||
// Reorder elements with static elements position preserved
|
||||
// There is case where all the static elements will not be available (API, Email channels, etc).
|
||||
// In that case, we need to preserve the order of the static elements and
|
||||
// insert them in the correct position.
|
||||
const reorderElementsWithStaticPreservation = (
|
||||
savedOrder = [],
|
||||
currentOrder = []
|
||||
) => {
|
||||
const finalOrder = [...currentOrder];
|
||||
const visibleKeys = new Set(currentOrder);
|
||||
|
||||
// Process hidden static elements from saved order
|
||||
savedOrder
|
||||
// Find static elements that aren't currently visible
|
||||
.filter(key => key.startsWith('static-') && !visibleKeys.has(key))
|
||||
.forEach(staticKey => {
|
||||
// Find next visible element after this static element in saved order
|
||||
const nextVisible = savedOrder
|
||||
.slice(savedOrder.indexOf(staticKey))
|
||||
.find(key => visibleKeys.has(key));
|
||||
|
||||
// If next visible element found, insert before it; otherwise add to end
|
||||
if (nextVisible) {
|
||||
finalOrder.splice(finalOrder.indexOf(nextVisible), 0, staticKey);
|
||||
} else {
|
||||
finalOrder.push(staticKey);
|
||||
}
|
||||
});
|
||||
|
||||
return finalOrder;
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragging.value = false;
|
||||
// Get the saved and current saved order
|
||||
const savedOrder = uiSettings.value[orderKey.value] ?? [];
|
||||
const currentOrder = combinedElements.value.map(({ key }) => key);
|
||||
|
||||
const finalOrder = reorderElementsWithStaticPreservation(
|
||||
savedOrder,
|
||||
currentOrder
|
||||
);
|
||||
|
||||
updateUISettings({
|
||||
[orderKey.value]: finalOrder,
|
||||
});
|
||||
};
|
||||
|
||||
const initializeSettings = () => {
|
||||
const currentOrder = uiSettings.value[orderKey.value];
|
||||
if (!currentOrder) {
|
||||
const initialOrder = combinedElements.value.map(element => element.key);
|
||||
updateUISettings({
|
||||
[orderKey.value]: initialOrder,
|
||||
});
|
||||
}
|
||||
|
||||
showAllAttributes.value =
|
||||
uiSettings.value[showMoreUISettingsKey.value] || false;
|
||||
uiSettings.value[`show_all_attributes_${props.attributeFrom}`] || false;
|
||||
};
|
||||
|
||||
const onClickToggle = () => {
|
||||
toggleShowAllAttributes();
|
||||
updateUISettings({
|
||||
[showMoreUISettingsKey.value]: showAllAttributes.value,
|
||||
[`show_all_attributes_${props.attributeFrom}`]: showAllAttributes.value,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -169,48 +260,65 @@ const evenClass = [
|
||||
'[&>*:nth-child(odd)]:!bg-n-background [&>*:nth-child(even)]:!bg-n-slate-2',
|
||||
'dark:[&>*:nth-child(odd)]:!bg-n-background dark:[&>*:nth-child(even)]:!bg-n-solid-1',
|
||||
];
|
||||
const oddClass = [
|
||||
'[&>*:nth-child(odd)]:!bg-n-slate-2 [&>*:nth-child(even)]:!bg-n-background',
|
||||
'dark:[&>*:nth-child(odd)]:!bg-n-solid-1 dark:[&>*:nth-child(even)]:!bg-n-background',
|
||||
];
|
||||
|
||||
const wrapperClass = computed(() => {
|
||||
return props.startAt === 'even' ? evenClass : oddClass;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- TODO: After migration to Vue 3, remove the top level div -->
|
||||
<template>
|
||||
<div :class="wrapperClass" class="last:rounded-b-lg">
|
||||
<CustomAttribute
|
||||
v-for="attribute in displayedAttributes"
|
||||
:key="attribute.id"
|
||||
class="last:rounded-b-lg border-b border-n-weak/50 dark:border-n-weak/90"
|
||||
:attribute-key="attribute.attribute_key"
|
||||
:attribute-type="attribute.attribute_display_type"
|
||||
:values="attribute.attribute_values"
|
||||
:label="attribute.attribute_display_name"
|
||||
:description="attribute.attribute_description"
|
||||
:value="attribute.value"
|
||||
show-actions
|
||||
:attribute-regex="attribute.regex_pattern"
|
||||
:regex-cue="attribute.regex_cue"
|
||||
:contact-id="contactId"
|
||||
@update="onUpdate"
|
||||
@delete="onDelete"
|
||||
@copy="onCopy"
|
||||
/>
|
||||
<div class="conversation--details">
|
||||
<Draggable
|
||||
:list="displayedElements"
|
||||
:disabled="!showAllAttributes"
|
||||
animation="200"
|
||||
ghost-class="ghost"
|
||||
handle=".drag-handle"
|
||||
item-key="key"
|
||||
class="last:rounded-b-lg overflow-hidden"
|
||||
:class="evenClass"
|
||||
@start="dragging = true"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div
|
||||
class="drag-handle relative border-b border-n-weak/50 dark:border-n-weak/90"
|
||||
:class="{
|
||||
'cursor-grab': showAllAttributes,
|
||||
'last:border-transparent dark:last:border-transparent':
|
||||
combinedElements.length <= 5,
|
||||
}"
|
||||
>
|
||||
<template v-if="element.type === 'static_attribute'">
|
||||
<slot name="staticItem" :element="element" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<CustomAttribute
|
||||
:key="element.id"
|
||||
:attribute-key="element.attribute_key"
|
||||
:attribute-type="element.attribute_display_type"
|
||||
:values="element.attribute_values"
|
||||
:label="element.attribute_display_name"
|
||||
:description="element.attribute_description"
|
||||
:value="element.value"
|
||||
show-actions
|
||||
:attribute-regex="element.regex_pattern"
|
||||
:regex-cue="element.regex_cue"
|
||||
:contact-id="contactId"
|
||||
@update="onUpdate"
|
||||
@delete="onDelete"
|
||||
@copy="onCopy"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
|
||||
<p
|
||||
v-if="!displayedAttributes.length && emptyStateMessage"
|
||||
class="p-3 text-center last:rounded-b-lg"
|
||||
v-if="!displayedElements.length && emptyStateMessage"
|
||||
class="p-3 text-center"
|
||||
>
|
||||
{{ emptyStateMessage }}
|
||||
</p>
|
||||
<!-- Show more and show less buttons show it if the filteredAttributes length is greater than 5 -->
|
||||
<div
|
||||
v-if="filteredAttributes.length > 5"
|
||||
class="flex px-2 py-2 last:rounded-b-lg"
|
||||
>
|
||||
<!-- Show more and show less buttons show it if the combinedElements length is greater than 5 -->
|
||||
<div v-if="combinedElements.length > 5" class="flex px-2 py-2">
|
||||
<woot-button
|
||||
size="small"
|
||||
:icon="showAllAttributes ? 'chevron-up' : 'chevron-down'"
|
||||
@@ -224,3 +332,9 @@ const wrapperClass = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ghost {
|
||||
@apply opacity-50 bg-n-slate-3 dark:bg-n-slate-9;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -121,6 +121,7 @@ export default {
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('INBOX.ALERTS.MARK_AS_READ'));
|
||||
this.$store.dispatch('notifications/unReadCount'); // to update the unread count in the store real time
|
||||
});
|
||||
},
|
||||
markNotificationAsUnRead(notification) {
|
||||
@@ -133,6 +134,7 @@ export default {
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('INBOX.ALERTS.MARK_AS_UNREAD'));
|
||||
this.$store.dispatch('notifications/unReadCount'); // to update the unread count in the store real time
|
||||
});
|
||||
},
|
||||
deleteNotification(notification) {
|
||||
@@ -186,12 +188,16 @@ export default {
|
||||
notificationType,
|
||||
});
|
||||
|
||||
this.$store.dispatch('notifications/read', {
|
||||
id,
|
||||
primaryActorId,
|
||||
primaryActorType,
|
||||
unreadCount: this.meta.unreadCount,
|
||||
});
|
||||
this.$store
|
||||
.dispatch('notifications/read', {
|
||||
id,
|
||||
primaryActorId,
|
||||
primaryActorType,
|
||||
unreadCount: this.meta.unreadCount,
|
||||
})
|
||||
.then(() => {
|
||||
this.$store.dispatch('notifications/unReadCount'); // to update the unread count in the store real time
|
||||
});
|
||||
|
||||
this.$router.push({
|
||||
name: 'inbox_view_conversation',
|
||||
|
||||
@@ -30,7 +30,7 @@ defineProps({
|
||||
</slot>
|
||||
<p
|
||||
v-else-if="noRecordsFound"
|
||||
class="flex-1 text-slate-700 dark:text-slate-100 flex items-center justify-center text-base"
|
||||
class="flex-1 py-20 text-slate-700 dark:text-slate-100 flex items-center justify-center text-base"
|
||||
>
|
||||
{{ noRecordsMessage }}
|
||||
</p>
|
||||
|
||||
@@ -1,123 +1,181 @@
|
||||
<script>
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import BillingItem from './components/BillingItem.vue';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export default {
|
||||
components: { BillingItem },
|
||||
setup() {
|
||||
const { accountId } = useAccount();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
return {
|
||||
accountId,
|
||||
formatMessage,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
getAccount: 'accounts/getAccount',
|
||||
uiFlags: 'accounts/getUIFlags',
|
||||
}),
|
||||
currentAccount() {
|
||||
return this.getAccount(this.accountId) || {};
|
||||
},
|
||||
customAttributes() {
|
||||
return this.currentAccount.custom_attributes || {};
|
||||
},
|
||||
hasABillingPlan() {
|
||||
return !!this.planName;
|
||||
},
|
||||
planName() {
|
||||
return this.customAttributes.plan_name || '';
|
||||
},
|
||||
subscribedQuantity() {
|
||||
return this.customAttributes.subscribed_quantity || 0;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchAccountDetails();
|
||||
},
|
||||
methods: {
|
||||
async fetchAccountDetails() {
|
||||
if (!this.hasABillingPlan) {
|
||||
this.$store.dispatch('accounts/subscription');
|
||||
}
|
||||
},
|
||||
onClickBillingPortal() {
|
||||
this.$store.dispatch('accounts/checkout');
|
||||
},
|
||||
onToggleChatWindow() {
|
||||
if (window.$chatwoot) {
|
||||
window.$chatwoot.toggle();
|
||||
}
|
||||
},
|
||||
},
|
||||
import BillingMeter from './components/BillingMeter.vue';
|
||||
import BillingCard from './components/BillingCard.vue';
|
||||
import BillingHeader from './components/BillingHeader.vue';
|
||||
import DetailItem from './components/DetailItem.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
const { currentAccount } = useAccount();
|
||||
const {
|
||||
captainEnabled,
|
||||
captainLimits,
|
||||
documentLimits,
|
||||
responseLimits,
|
||||
fetchLimits,
|
||||
} = useCaptain();
|
||||
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
const store = useStore();
|
||||
const customAttributes = computed(() => {
|
||||
return currentAccount.value.custom_attributes || {};
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed property for plan name
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
const planName = computed(() => {
|
||||
return customAttributes.value.plan_name;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed property for subscribed quantity
|
||||
* @returns {number|undefined}
|
||||
*/
|
||||
const subscribedQuantity = computed(() => {
|
||||
return customAttributes.value.subscribed_quantity;
|
||||
});
|
||||
|
||||
const subscriptionRenewsOn = computed(() => {
|
||||
if (!customAttributes.value.subscription_ends_on) return '';
|
||||
const endDate = new Date(customAttributes.value.subscription_ends_on);
|
||||
// return date as 12 Jan, 2034
|
||||
return format(endDate, 'dd MMM, yyyy');
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed property indicating if user has a billing plan
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const hasABillingPlan = computed(() => {
|
||||
return !!planName.value;
|
||||
});
|
||||
|
||||
const fetchAccountDetails = async () => {
|
||||
if (!hasABillingPlan.value) {
|
||||
store.dispatch('accounts/subscription');
|
||||
fetchLimits();
|
||||
}
|
||||
};
|
||||
|
||||
const onClickBillingPortal = () => {
|
||||
store.dispatch('accounts/checkout');
|
||||
};
|
||||
|
||||
const onToggleChatWindow = () => {
|
||||
if (window.$chatwoot) {
|
||||
window.$chatwoot.toggle();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchAccountDetails);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 p-6 overflow-auto dark:bg-slate-900">
|
||||
<woot-loading-state v-if="uiFlags.isFetchingItem" />
|
||||
<div v-else-if="!hasABillingPlan">
|
||||
<p>{{ $t('BILLING_SETTINGS.NO_BILLING_USER') }}</p>
|
||||
</div>
|
||||
<div v-else class="w-full">
|
||||
<div class="current-plan--details">
|
||||
<h6>{{ $t('BILLING_SETTINGS.CURRENT_PLAN.TITLE') }}</h6>
|
||||
<div
|
||||
v-dompurify-html="
|
||||
formatMessage(
|
||||
$t('BILLING_SETTINGS.CURRENT_PLAN.PLAN_NOTE', {
|
||||
plan: planName,
|
||||
quantity: subscribedQuantity,
|
||||
})
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<BillingItem
|
||||
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
|
||||
:button-label="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT')"
|
||||
@open="onClickBillingPortal"
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.isFetchingItem"
|
||||
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
|
||||
:no-records-found="!hasABillingPlan"
|
||||
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('BILLING_SETTINGS.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.DESCRIPTION')"
|
||||
:link-text="$t('BILLING_SETTINGS.VIEW_PRICING')"
|
||||
feature-name="billing"
|
||||
/>
|
||||
<BillingItem
|
||||
:title="$t('BILLING_SETTINGS.CHAT_WITH_US.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.CHAT_WITH_US.DESCRIPTION')"
|
||||
:button-label="$t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT')"
|
||||
button-icon="chat-multiple"
|
||||
@open="onToggleChatWindow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<section class="grid gap-4">
|
||||
<BillingCard
|
||||
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
|
||||
>
|
||||
<template #action>
|
||||
<ButtonV4 sm solid blue @click="onClickBillingPortal">
|
||||
{{ $t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT') }}
|
||||
</ButtonV4>
|
||||
</template>
|
||||
<div
|
||||
v-if="planName || subscribedQuantity || subscriptionRenewsOn"
|
||||
class="grid lg:grid-cols-4 sm:grid-cols-3 grid-cols-1 gap-2 divide-x divide-n-weak"
|
||||
>
|
||||
<DetailItem
|
||||
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.TITLE')"
|
||||
:value="planName"
|
||||
/>
|
||||
<DetailItem
|
||||
v-if="subscribedQuantity"
|
||||
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.SEAT_COUNT')"
|
||||
:value="subscribedQuantity"
|
||||
/>
|
||||
<DetailItem
|
||||
v-if="subscriptionRenewsOn"
|
||||
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
|
||||
:value="subscriptionRenewsOn"
|
||||
/>
|
||||
</div>
|
||||
</BillingCard>
|
||||
<BillingCard
|
||||
v-if="captainEnabled"
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
|
||||
>
|
||||
<template #action>
|
||||
<ButtonV4 sm faded slate disabled>
|
||||
{{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
|
||||
</ButtonV4>
|
||||
</template>
|
||||
<div v-if="captainLimits && responseLimits" class="px-5">
|
||||
<BillingMeter
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN.RESPONSES')"
|
||||
v-bind="responseLimits"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="captainLimits && documentLimits" class="px-5">
|
||||
<BillingMeter
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN.DOCUMENTS')"
|
||||
v-bind="documentLimits"
|
||||
/>
|
||||
</div>
|
||||
</BillingCard>
|
||||
<BillingCard
|
||||
v-else
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.CAPTAIN.UPGRADE')"
|
||||
>
|
||||
<template #action>
|
||||
<ButtonV4 sm solid slate @click="onClickBillingPortal">
|
||||
{{ $t('CAPTAIN.PAYWALL.UPGRADE_NOW') }}
|
||||
</ButtonV4>
|
||||
</template>
|
||||
</BillingCard>
|
||||
|
||||
<BillingHeader
|
||||
class="px-1 mt-5"
|
||||
:title="$t('BILLING_SETTINGS.CHAT_WITH_US.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.CHAT_WITH_US.DESCRIPTION')"
|
||||
>
|
||||
<ButtonV4
|
||||
sm
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-life-buoy"
|
||||
@open="onToggleChatWindow"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
|
||||
</ButtonV4>
|
||||
</BillingHeader>
|
||||
</section>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.manage-subscription {
|
||||
@apply bg-white dark:bg-slate-800 flex justify-between mb-2 py-6 px-4 items-center rounded-md border border-solid border-slate-75 dark:border-slate-700;
|
||||
}
|
||||
|
||||
.current-plan--details {
|
||||
@apply border-b border-solid border-slate-75 dark:border-slate-800 mb-4 pb-4;
|
||||
|
||||
h6 {
|
||||
@apply text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply text-slate-600 dark:text-slate-200;
|
||||
}
|
||||
}
|
||||
|
||||
.manage-subscription {
|
||||
.manage-subscription--description {
|
||||
@apply mb-0 text-slate-600 dark:text-slate-200;
|
||||
}
|
||||
|
||||
h6 {
|
||||
@apply text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import SettingsContent from '../Wrapper.vue';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
|
||||
export default {
|
||||
@@ -9,7 +9,7 @@ export default {
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
component: SettingsContent,
|
||||
component: SettingsWrapper,
|
||||
props: {
|
||||
headerTitle: 'BILLING_SETTINGS.TITLE',
|
||||
icon: 'credit-card-person',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import BillingHeader from './BillingHeader.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-xl shadow-sm border border-n-weak bg-n-solid-2 py-5 space-y-5"
|
||||
>
|
||||
<BillingHeader :title :description class="px-5">
|
||||
<slot name="action" />
|
||||
</BillingHeader>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-[1fr_200px] gap-5">
|
||||
<div>
|
||||
<span class="text-base font-medium text-n-slate-12">
|
||||
{{ title }}
|
||||
</span>
|
||||
<p class="text-sm mt-1 text-n-slate-11">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,40 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
buttonIcon: {
|
||||
type: String,
|
||||
default: 'edit',
|
||||
},
|
||||
},
|
||||
emits: ['open'],
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="manage-subscription">
|
||||
<div>
|
||||
<h6>{{ title }}</h6>
|
||||
<p class="manage-subscription--description">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<woot-button variant="smooth" :icon="buttonIcon" @click="$emit('open')">
|
||||
{{ buttonLabel }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
consumed: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const percent = computed(() =>
|
||||
Math.round((props.consumed / props.totalCount) * 100)
|
||||
);
|
||||
|
||||
const colorClass = computed(() => {
|
||||
if (percent.value < 50) {
|
||||
return 'bg-n-teal-10';
|
||||
}
|
||||
if (percent.value < 80) {
|
||||
return 'bg-n-amber-10';
|
||||
}
|
||||
return 'bg-n-ruby-10';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex gap-5 items-center justify-between text-xs uppercase text-n-slate-10"
|
||||
>
|
||||
<div class="font-medium tracking-wider">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="tabular-nums">{{ consumed }} / {{ totalCount }}</div>
|
||||
</div>
|
||||
<div class="rounded-full overflow-hidden h-2 w-full bg-n-slate-4 mt-2">
|
||||
<div class="h-2" :class="colorClass" :style="{ width: `${percent}%` }" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-5">
|
||||
<span class="text-n-slate-11 text-xs">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="mt-2 text-xl font-medium text-n-slate-12">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+16
-25
@@ -1,4 +1,7 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
featurePrefix: {
|
||||
type: String,
|
||||
@@ -23,56 +26,44 @@ const emit = defineEmits(['upgrade']);
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col max-w-md px-6 py-6 bg-white border shadow dark:bg-slate-800 rounded-xl border-slate-100 dark:border-slate-900"
|
||||
class="flex flex-col max-w-md px-6 py-6 border shadow bg-n-solid-1 rounded-xl border-n-weak"
|
||||
>
|
||||
<div class="flex items-center w-full gap-2 mb-4">
|
||||
<span
|
||||
class="flex items-center justify-center w-6 h-6 rounded-full bg-woot-75/70 dark:bg-woot-800/40"
|
||||
class="flex items-center justify-center w-6 h-6 rounded-full bg-n-solid-blue"
|
||||
>
|
||||
<fluent-icon
|
||||
size="14"
|
||||
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
|
||||
icon="lock-closed"
|
||||
<Icon
|
||||
class="flex-shrink-0 text-n-brand size-[14px]"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-base font-medium text-slate-900 dark:text-white">
|
||||
<span class="text-base font-medium text-n-slate-12">
|
||||
{{ $t(`${featurePrefix}.PAYWALL.TITLE`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-normal"
|
||||
class="text-sm font-normal text-n-slate-11"
|
||||
v-html="$t(`${featurePrefix}.${i18nKey}.AVAILABLE_ON`)"
|
||||
/>
|
||||
<p class="text-sm font-normal">
|
||||
<p class="text-sm font-normal text-n-slate-11">
|
||||
{{ $t(`${featurePrefix}.${i18nKey}.UPGRADE_PROMPT`) }}
|
||||
<span v-if="!isOnChatwootCloud && !isSuperAdmin">
|
||||
{{ $t(`${featurePrefix}.ENTERPRISE_PAYWALL.ASK_ADMIN`) }}
|
||||
</span>
|
||||
</p>
|
||||
<template v-if="isOnChatwootCloud">
|
||||
<woot-button
|
||||
color-scheme="primary"
|
||||
class="w-full mt-2 text-center rounded-xl"
|
||||
size="expanded"
|
||||
is-expanded
|
||||
@click="emit('upgrade')"
|
||||
>
|
||||
<ButtonV4 blue solid md @click="emit('upgrade')">
|
||||
{{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }}
|
||||
</woot-button>
|
||||
<span class="mt-2 text-xs tracking-tight text-center">
|
||||
</ButtonV4>
|
||||
<span class="mt-2 text-xs tracking-tight text-center text-n-slate-11">
|
||||
{{ $t(`${featurePrefix}.PAYWALL.CANCEL_ANYTIME`) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="isSuperAdmin">
|
||||
<a href="/super_admin" class="block w-full">
|
||||
<woot-button
|
||||
color-scheme="primary"
|
||||
class="w-full mt-2 text-center rounded-xl"
|
||||
size="expanded"
|
||||
is-expanded
|
||||
>
|
||||
<ButtonV4 solid blue md class="w-full">
|
||||
{{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }}
|
||||
</woot-button>
|
||||
</ButtonV4>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
import SummaryReports from './components/SummaryReports.vue';
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const summarReportsRef = ref(null);
|
||||
|
||||
const onDownloadClick = () => {
|
||||
summarReportsRef.value.downloadReports();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportHeader
|
||||
:header-title="$t('AGENT_REPORTS.HEADER')"
|
||||
:header-description="$t('AGENT_REPORTS.DESCRIPTION')"
|
||||
>
|
||||
<V4Button
|
||||
:label="$t('AGENT_REPORTS.DOWNLOAD_AGENT_REPORTS')"
|
||||
icon="i-ph-download-simple"
|
||||
size="sm"
|
||||
@click="onDownloadClick"
|
||||
/>
|
||||
</ReportHeader>
|
||||
|
||||
<SummaryReports
|
||||
ref="summarReportsRef"
|
||||
action-key="summaryReports/fetchAgentSummaryReports"
|
||||
getter-key="agents/getAgents"
|
||||
fetch-items-key="agents/get"
|
||||
summary-key="summaryReports/getAgentSummaryReports"
|
||||
type="agent"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useFunctionGetter, useStore } from 'dashboard/composables/store';
|
||||
|
||||
import WootReports from './components/WootReports.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const agent = useFunctionGetter('agents/getAgentById', route.params.id);
|
||||
|
||||
onMounted(() => store.dispatch('agents/get'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WootReports
|
||||
v-if="agent.id"
|
||||
:key="agent.id"
|
||||
type="agent"
|
||||
getter-key="agents/getAgents"
|
||||
action-key="agents/get"
|
||||
:selected-item="agent"
|
||||
:download-button-label="$t('AGENT_REPORTS.DOWNLOAD_AGENT_REPORTS')"
|
||||
:report-title="$t('AGENT_REPORTS.HEADER')"
|
||||
has-back-button
|
||||
/>
|
||||
<div v-else class="w-full py-20">
|
||||
<Spinner class="mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
import SummaryReports from './components/SummaryReports.vue';
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const summarReportsRef = ref(null);
|
||||
|
||||
const onDownloadClick = () => {
|
||||
summarReportsRef.value.downloadReports();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportHeader
|
||||
:header-title="$t('INBOX_REPORTS.HEADER')"
|
||||
:header-description="$t('INBOX_REPORTS.DESCRIPTION')"
|
||||
>
|
||||
<V4Button
|
||||
:label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
|
||||
icon="i-ph-download-simple"
|
||||
size="sm"
|
||||
@click="onDownloadClick"
|
||||
/>
|
||||
</ReportHeader>
|
||||
|
||||
<SummaryReports
|
||||
ref="summarReportsRef"
|
||||
action-key="summaryReports/fetchInboxSummaryReports"
|
||||
getter-key="inboxes/getInboxes"
|
||||
fetch-items-key="inboxes/get"
|
||||
summary-key="summaryReports/getInboxSummaryReports"
|
||||
type="inbox"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useFunctionGetter } from 'dashboard/composables/store';
|
||||
|
||||
import WootReports from './components/WootReports.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const inbox = useFunctionGetter('inboxes/getInboxById', route.params.id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WootReports
|
||||
v-if="inbox.id"
|
||||
:key="inbox.id"
|
||||
type="inbox"
|
||||
getter-key="inboxes/getInboxes"
|
||||
action-key="inboxes/get"
|
||||
:selected-item="inbox"
|
||||
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
|
||||
:report-title="$t('INBOX_REPORTS.HEADER')"
|
||||
has-back-button
|
||||
/>
|
||||
<div v-else class="w-full py-20">
|
||||
<Spinner class="mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
import SummaryReports from './components/SummaryReports.vue';
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const summarReportsRef = ref(null);
|
||||
|
||||
const onDownloadClick = () => {
|
||||
summarReportsRef.value.downloadReports();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportHeader
|
||||
:header-title="$t('TEAM_REPORTS.HEADER')"
|
||||
:header-description="$t('TEAM_REPORTS.DESCRIPTION')"
|
||||
>
|
||||
<V4Button
|
||||
:label="$t('TEAM_REPORTS.DOWNLOAD_TEAM_REPORTS')"
|
||||
icon="i-ph-download-simple"
|
||||
size="sm"
|
||||
@click="onDownloadClick"
|
||||
/>
|
||||
</ReportHeader>
|
||||
|
||||
<SummaryReports
|
||||
ref="summarReportsRef"
|
||||
action-key="summaryReports/fetchTeamSummaryReports"
|
||||
getter-key="teams/getTeams"
|
||||
fetch-items-key="teams/get"
|
||||
summary-key="summaryReports/getTeamSummaryReports"
|
||||
type="team"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useFunctionGetter } from 'dashboard/composables/store';
|
||||
|
||||
import WootReports from './components/WootReports.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const team = useFunctionGetter('teams/getTeamById', route.params.id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WootReports
|
||||
v-if="team.id"
|
||||
:key="team.id"
|
||||
type="team"
|
||||
getter-key="teams/getTeams"
|
||||
action-key="teams/get"
|
||||
:selected-item="team"
|
||||
:download-button-label="$t('TEAM_REPORTS.DOWNLOAD_TEAM_REPORTS')"
|
||||
:report-title="$t('TEAM_REPORTS.HEADER')"
|
||||
has-back-button
|
||||
/>
|
||||
<div v-else class="w-full py-20">
|
||||
<Spinner class="mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
+8
-2
@@ -15,6 +15,10 @@ export default {
|
||||
Thumbnail,
|
||||
},
|
||||
props: {
|
||||
currentFilter: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
filterItemsList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@@ -40,7 +44,7 @@ export default {
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
currentSelectedFilter: null,
|
||||
currentSelectedFilter: this.currentFilter || null,
|
||||
currentDateRangeSelection: {
|
||||
id: 0,
|
||||
name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||
@@ -113,7 +117,9 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
filterItemsList(val) {
|
||||
this.currentSelectedFilter = val[0];
|
||||
this.currentSelectedFilter = !this.currentFilter
|
||||
? val[0]
|
||||
: this.currentFilter;
|
||||
this.changeFilterSelection();
|
||||
},
|
||||
groupByFilterItemsList() {
|
||||
|
||||
+30
-6
@@ -1,17 +1,41 @@
|
||||
<script setup>
|
||||
import BackButton from 'dashboard/components/widgets/BackButton.vue';
|
||||
|
||||
defineProps({
|
||||
headerTitle: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
headerDescription: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasBackButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<slot />
|
||||
</div>
|
||||
<section class="flex flex-col gap-1 pt-10 pb-5">
|
||||
<div v-if="hasBackButton">
|
||||
<BackButton compact />
|
||||
</div>
|
||||
<div class="flex justify-between w-full gap-5">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<p v-if="headerDescription" class="text-n-slate-12 mt-2">
|
||||
{{ headerDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
row: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const routeName = computed(() => `${props.row.original.type}_reports_show`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link
|
||||
:to="{ name: routeName, params: { id: row.original.id } }"
|
||||
class="text-n-slate-12 hover:underline"
|
||||
>
|
||||
{{ row.original.name }}
|
||||
</router-link>
|
||||
</template>
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
<script setup>
|
||||
import ReportFilterSelector from './FilterSelector.vue';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import Table from 'dashboard/components/table/Table.vue';
|
||||
import { generateFileName } from 'dashboard/helper/downloadHelper';
|
||||
import {
|
||||
useVueTable,
|
||||
createColumnHelper,
|
||||
getCoreRowModel,
|
||||
} from '@tanstack/vue-table';
|
||||
import { computed, onMounted, ref, h } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'account',
|
||||
},
|
||||
getterKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
actionKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
summaryKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
fetchItemsKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const from = ref(0);
|
||||
const to = ref(0);
|
||||
const businessHours = ref(false);
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import SummaryReportLink from './SummaryReportLink.vue';
|
||||
|
||||
const rowItems = useMapGetter([props.getterKey]) || [];
|
||||
const reportMetrics = useMapGetter([props.summaryKey]) || [];
|
||||
|
||||
const getMetrics = id =>
|
||||
reportMetrics.value.find(metrics => metrics.id === Number(id)) || {};
|
||||
const columnHelper = createColumnHelper();
|
||||
const { t } = useI18n();
|
||||
|
||||
const defaulSpanRender = cellProps =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: cellProps.getValue() ? '' : 'text-n-slate-12',
|
||||
},
|
||||
cellProps.getValue()
|
||||
);
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor('name', {
|
||||
header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`),
|
||||
width: 300,
|
||||
cell: cellProps => h(SummaryReportLink, cellProps),
|
||||
}),
|
||||
columnHelper.accessor('conversationsCount', {
|
||||
header: t('SUMMARY_REPORTS.CONVERSATIONS'),
|
||||
width: 200,
|
||||
cell: defaulSpanRender,
|
||||
}),
|
||||
columnHelper.accessor('avgFirstResponseTime', {
|
||||
header: t('SUMMARY_REPORTS.AVG_FIRST_RESPONSE_TIME'),
|
||||
width: 200,
|
||||
cell: defaulSpanRender,
|
||||
}),
|
||||
columnHelper.accessor('avgResolutionTime', {
|
||||
header: t('SUMMARY_REPORTS.AVG_RESOLUTION_TIME'),
|
||||
width: 200,
|
||||
cell: defaulSpanRender,
|
||||
}),
|
||||
columnHelper.accessor('avgReplyTime', {
|
||||
header: t('SUMMARY_REPORTS.AVG_REPLY_TIME'),
|
||||
width: 200,
|
||||
cell: defaulSpanRender,
|
||||
}),
|
||||
columnHelper.accessor('resolutionsCount', {
|
||||
header: t('SUMMARY_REPORTS.RESOLUTION_COUNT'),
|
||||
width: 200,
|
||||
cell: defaulSpanRender,
|
||||
}),
|
||||
];
|
||||
|
||||
const renderAvgTime = value => (value ? formatTime(value) : '--');
|
||||
|
||||
const renderCount = value => (value ? value.toLocaleString() : '--');
|
||||
|
||||
const tableData = computed(() =>
|
||||
rowItems.value.map(row => {
|
||||
const rowMetrics = getMetrics(row.id);
|
||||
const {
|
||||
conversationsCount,
|
||||
avgFirstResponseTime,
|
||||
avgResolutionTime,
|
||||
avgReplyTime,
|
||||
resolvedConversationsCount,
|
||||
} = rowMetrics;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: props.type,
|
||||
conversationsCount: renderCount(conversationsCount),
|
||||
avgFirstResponseTime: renderAvgTime(avgFirstResponseTime),
|
||||
avgReplyTime: renderAvgTime(avgReplyTime),
|
||||
avgResolutionTime: renderAvgTime(avgResolutionTime),
|
||||
resolutionsCount: renderCount(resolvedConversationsCount),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const fetchAllData = () => {
|
||||
store.dispatch(props.fetchItemsKey);
|
||||
store.dispatch(props.actionKey, {
|
||||
since: from.value,
|
||||
until: to.value,
|
||||
businessHours: businessHours.value,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => fetchAllData());
|
||||
|
||||
const onFilterChange = updatedFilter => {
|
||||
from.value = updatedFilter.from;
|
||||
to.value = updatedFilter.to;
|
||||
businessHours.value = updatedFilter.businessHours;
|
||||
fetchAllData();
|
||||
};
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return tableData.value;
|
||||
},
|
||||
columns,
|
||||
enableSorting: false,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
// downloadReports method is not used in this component
|
||||
// but it is exposed to be used in the parent component
|
||||
const downloadReports = () => {
|
||||
const dispatchMethods = {
|
||||
agent: 'downloadAgentReports',
|
||||
label: 'downloadLabelReports',
|
||||
inbox: 'downloadInboxReports',
|
||||
team: 'downloadTeamReports',
|
||||
};
|
||||
if (dispatchMethods[props.type]) {
|
||||
const fileName = generateFileName({
|
||||
type: props.type,
|
||||
to: to.value,
|
||||
businessHours: businessHours.value,
|
||||
});
|
||||
const params = {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
fileName,
|
||||
businessHours: businessHours.value,
|
||||
};
|
||||
store.dispatch(dispatchMethods[props.type], params);
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ downloadReports });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportFilterSelector @filter-change="onFilterChange" />
|
||||
<div
|
||||
class="flex-1 overflow-auto px-5 py-6 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
>
|
||||
<Table :table="table" />
|
||||
</div>
|
||||
</template>
|
||||
+11
-3
@@ -54,12 +54,20 @@ export default {
|
||||
type: String,
|
||||
default: 'Download Reports',
|
||||
},
|
||||
hasBackButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedItem: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
from: 0,
|
||||
to: 0,
|
||||
selectedFilter: null,
|
||||
selectedFilter: this.selectedItem,
|
||||
groupBy: GROUP_BY_FILTER[1],
|
||||
groupByfilterItemsList: GROUP_BY_OPTIONS.DAY.map(this.translateOptions),
|
||||
selectedGroupByFilter: null,
|
||||
@@ -206,7 +214,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportHeader :header-title="reportTitle">
|
||||
<ReportHeader :header-title="reportTitle" :has-back-button="hasBackButton">
|
||||
<V4Button
|
||||
:label="downloadButtonLabel"
|
||||
icon="i-ph-download-simple"
|
||||
@@ -214,13 +222,13 @@ export default {
|
||||
@click="downloadReports"
|
||||
/>
|
||||
</ReportHeader>
|
||||
|
||||
<ReportFilters
|
||||
v-if="filterItemsList"
|
||||
:type="type"
|
||||
:filter-items-list="filterItemsList"
|
||||
:group-by-filter-items-list="groupByfilterItemsList"
|
||||
:selected-group-by-filter="selectedGroupByFilter"
|
||||
:current-filter="selectedFilter"
|
||||
@date-range-change="onDateRangeChange"
|
||||
@filter-change="onFilterChange"
|
||||
@group-by-filter-change="onGroupByFilterChange"
|
||||
|
||||
@@ -3,15 +3,112 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import ReportsWrapper from './components/ReportsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
|
||||
import AgentReportsIndex from './AgentReportsIndex.vue';
|
||||
import InboxReportsIndex from './InboxReportsIndex.vue';
|
||||
import TeamReportsIndex from './TeamReportsIndex.vue';
|
||||
|
||||
import AgentReportsShow from './AgentReportsShow.vue';
|
||||
import InboxReportsShow from './InboxReportsShow.vue';
|
||||
import TeamReportsShow from './TeamReportsShow.vue';
|
||||
|
||||
import AgentReports from './AgentReports.vue';
|
||||
import LabelReports from './LabelReports.vue';
|
||||
import InboxReports from './InboxReports.vue';
|
||||
import LabelReports from './LabelReports.vue';
|
||||
import TeamReports from './TeamReports.vue';
|
||||
|
||||
import CsatResponses from './CsatResponses.vue';
|
||||
import BotReports from './BotReports.vue';
|
||||
import LiveReports from './LiveReports.vue';
|
||||
import SLAReports from './SLAReports.vue';
|
||||
|
||||
const oldReportRoutes = [
|
||||
{
|
||||
path: 'agent',
|
||||
name: 'agent_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: AgentReports,
|
||||
},
|
||||
{
|
||||
path: 'inboxes',
|
||||
name: 'inbox_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: InboxReports,
|
||||
},
|
||||
{
|
||||
path: 'label',
|
||||
name: 'label_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: LabelReports,
|
||||
},
|
||||
{
|
||||
path: 'teams',
|
||||
name: 'team_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: TeamReports,
|
||||
},
|
||||
];
|
||||
|
||||
const revisedReportRoutes = [
|
||||
{
|
||||
path: 'agents_overview',
|
||||
name: 'agent_reports_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: AgentReportsIndex,
|
||||
},
|
||||
{
|
||||
path: 'agents/:id',
|
||||
name: 'agent_reports_show',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: AgentReportsShow,
|
||||
},
|
||||
|
||||
{
|
||||
path: 'inboxes_overview',
|
||||
name: 'inbox_reports_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: InboxReportsIndex,
|
||||
},
|
||||
{
|
||||
path: 'inboxes/:id',
|
||||
name: 'inbox_reports_show',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: InboxReportsShow,
|
||||
},
|
||||
{
|
||||
path: 'teams_overview',
|
||||
name: 'team_reports_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: TeamReportsIndex,
|
||||
},
|
||||
{
|
||||
path: 'teams/:id',
|
||||
name: 'team_reports_show',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: TeamReportsShow,
|
||||
},
|
||||
];
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
@@ -40,38 +137,8 @@ export default {
|
||||
},
|
||||
component: Index,
|
||||
},
|
||||
{
|
||||
path: 'agent',
|
||||
name: 'agent_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: AgentReports,
|
||||
},
|
||||
{
|
||||
path: 'label',
|
||||
name: 'label_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: LabelReports,
|
||||
},
|
||||
{
|
||||
path: 'inboxes',
|
||||
name: 'inbox_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: InboxReports,
|
||||
},
|
||||
{
|
||||
path: 'teams',
|
||||
name: 'team_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: TeamReports,
|
||||
},
|
||||
...oldReportRoutes,
|
||||
...revisedReportRoutes,
|
||||
{
|
||||
path: 'sla',
|
||||
name: 'sla_reports',
|
||||
|
||||
@@ -5,8 +5,8 @@ import agentBots from './modules/agentBots';
|
||||
import agents from './modules/agents';
|
||||
import articles from './modules/helpCenterArticles';
|
||||
import attributes from './modules/attributes';
|
||||
import auth from './modules/auth';
|
||||
import auditlogs from './modules/auditlogs';
|
||||
import auth from './modules/auth';
|
||||
import automations from './modules/automations';
|
||||
import bulkActions from './modules/bulkActions';
|
||||
import campaigns from './modules/campaigns';
|
||||
@@ -25,9 +25,10 @@ import conversationStats from './modules/conversationStats';
|
||||
import conversationTypingStatus from './modules/conversationTypingStatus';
|
||||
import conversationWatchers from './modules/conversationWatchers';
|
||||
import csat from './modules/csat';
|
||||
import customViews from './modules/customViews';
|
||||
import customRole from './modules/customRole';
|
||||
import customViews from './modules/customViews';
|
||||
import dashboardApps from './modules/dashboardApps';
|
||||
import draftMessages from './modules/draftMessages';
|
||||
import globalConfig from 'shared/store/globalConfig';
|
||||
import inboxAssignableAgents from './modules/inboxAssignableAgents';
|
||||
import inboxes from './modules/inboxes';
|
||||
@@ -39,12 +40,12 @@ import notifications from './modules/notifications';
|
||||
import portals from './modules/helpCenterPortals';
|
||||
import reports from './modules/reports';
|
||||
import sla from './modules/sla';
|
||||
import slaReports from './modules/SLAReports';
|
||||
import summaryReports from './modules/summaryReports';
|
||||
import teamMembers from './modules/teamMembers';
|
||||
import teams from './modules/teams';
|
||||
import userNotificationSettings from './modules/userNotificationSettings';
|
||||
import webhooks from './modules/webhooks';
|
||||
import draftMessages from './modules/draftMessages';
|
||||
import SLAReports from './modules/SLAReports';
|
||||
import captainAssistants from './captain/assistant';
|
||||
import captainDocuments from './captain/document';
|
||||
import captainResponses from './captain/response';
|
||||
@@ -58,9 +59,9 @@ export default createStore({
|
||||
agents,
|
||||
articles,
|
||||
attributes,
|
||||
auditlogs,
|
||||
auth,
|
||||
automations,
|
||||
auditlogs,
|
||||
bulkActions,
|
||||
campaigns,
|
||||
cannedResponse,
|
||||
@@ -78,9 +79,10 @@ export default createStore({
|
||||
conversationTypingStatus,
|
||||
conversationWatchers,
|
||||
csat,
|
||||
customViews,
|
||||
customRole,
|
||||
customViews,
|
||||
dashboardApps,
|
||||
draftMessages,
|
||||
globalConfig,
|
||||
inboxAssignableAgents,
|
||||
inboxes,
|
||||
@@ -91,13 +93,13 @@ export default createStore({
|
||||
notifications,
|
||||
portals,
|
||||
reports,
|
||||
sla,
|
||||
slaReports,
|
||||
summaryReports,
|
||||
teamMembers,
|
||||
teams,
|
||||
userNotificationSettings,
|
||||
webhooks,
|
||||
draftMessages,
|
||||
sla,
|
||||
slaReports: SLAReports,
|
||||
captainAssistants,
|
||||
captainDocuments,
|
||||
captainResponses,
|
||||
|
||||
@@ -22,6 +22,9 @@ export const getters = {
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
getAgentById: $state => id => {
|
||||
return $state.records.find(record => record.id === Number(id)) || {};
|
||||
},
|
||||
getAgentStatus($state) {
|
||||
let status = {
|
||||
online: $state.records.filter(
|
||||
|
||||
@@ -35,4 +35,7 @@ export const getters = {
|
||||
getNotificationFilters($state) {
|
||||
return $state.notificationFilters;
|
||||
},
|
||||
getHasUnreadNotifications: $state => {
|
||||
return $state.meta.unreadCount > 0;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ const state = {
|
||||
meta: {
|
||||
count: 0,
|
||||
currentPage: 1,
|
||||
unReadCount: 0,
|
||||
unreadCount: 0,
|
||||
},
|
||||
records: {},
|
||||
uiFlags: {
|
||||
|
||||
@@ -95,4 +95,27 @@ describe('#getters', () => {
|
||||
state.notificationFilters
|
||||
);
|
||||
});
|
||||
|
||||
describe('getHasUnreadNotifications', () => {
|
||||
it('should return true when there are unread notifications', () => {
|
||||
const state = {
|
||||
meta: { unreadCount: 5 },
|
||||
};
|
||||
expect(getters.getHasUnreadNotifications(state)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when there are no unread notifications', () => {
|
||||
const state = {
|
||||
meta: { unreadCount: 0 },
|
||||
};
|
||||
expect(getters.getHasUnreadNotifications(state)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when meta is empty', () => {
|
||||
const state = {
|
||||
meta: {},
|
||||
};
|
||||
expect(getters.getHasUnreadNotifications(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import SummaryReportsAPI from 'dashboard/api/summaryReports';
|
||||
import store, { initialState } from '../summaryReports';
|
||||
|
||||
vi.mock('dashboard/api/summaryReports', () => ({
|
||||
default: {
|
||||
getInboxReports: vi.fn(),
|
||||
getAgentReports: vi.fn(),
|
||||
getTeamReports: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Summary Reports Store', () => {
|
||||
let commit;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
commit = vi.fn();
|
||||
});
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should have the correct initial state structure', () => {
|
||||
expect(initialState).toEqual({
|
||||
inboxSummaryReports: [],
|
||||
agentSummaryReports: [],
|
||||
teamSummaryReports: [],
|
||||
uiFlags: {
|
||||
isFetchingInboxSummaryReports: false,
|
||||
isFetchingAgentSummaryReports: false,
|
||||
isFetchingTeamSummaryReports: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Getters', () => {
|
||||
const state = {
|
||||
inboxSummaryReports: [{ id: 1 }],
|
||||
agentSummaryReports: [{ id: 2 }],
|
||||
teamSummaryReports: [{ id: 3 }],
|
||||
uiFlags: { isFetchingInboxSummaryReports: true },
|
||||
};
|
||||
|
||||
it('should return inbox summary reports', () => {
|
||||
expect(store.getters.getInboxSummaryReports(state)).toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
it('should return agent summary reports', () => {
|
||||
expect(store.getters.getAgentSummaryReports(state)).toEqual([{ id: 2 }]);
|
||||
});
|
||||
|
||||
it('should return team summary reports', () => {
|
||||
expect(store.getters.getTeamSummaryReports(state)).toEqual([{ id: 3 }]);
|
||||
});
|
||||
|
||||
it('should return UI flags', () => {
|
||||
expect(store.getters.getUIFlags(state)).toEqual({
|
||||
isFetchingInboxSummaryReports: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mutations', () => {
|
||||
it('should set inbox summary report', () => {
|
||||
const state = { ...initialState };
|
||||
const data = [{ id: 1 }];
|
||||
|
||||
store.mutations.setInboxSummaryReport(state, data);
|
||||
expect(state.inboxSummaryReports).toEqual(data);
|
||||
});
|
||||
|
||||
it('should set agent summary report', () => {
|
||||
const state = { ...initialState };
|
||||
const data = [{ id: 2 }];
|
||||
|
||||
store.mutations.setAgentSummaryReport(state, data);
|
||||
expect(state.agentSummaryReports).toEqual(data);
|
||||
});
|
||||
|
||||
it('should set team summary report', () => {
|
||||
const state = { ...initialState };
|
||||
const data = [{ id: 3 }];
|
||||
|
||||
store.mutations.setTeamSummaryReport(state, data);
|
||||
expect(state.teamSummaryReports).toEqual(data);
|
||||
});
|
||||
|
||||
it('should merge UI flags with existing flags', () => {
|
||||
const state = {
|
||||
uiFlags: { flag1: true, flag2: false },
|
||||
};
|
||||
const newFlags = { flag2: true, flag3: true };
|
||||
|
||||
store.mutations.setUIFlags(state, newFlags);
|
||||
expect(state.uiFlags).toEqual({
|
||||
flag1: true,
|
||||
flag2: true,
|
||||
flag3: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Actions', () => {
|
||||
describe('fetchInboxSummaryReports', () => {
|
||||
it('should fetch inbox reports successfully', async () => {
|
||||
const params = { date: '2025-01-01' };
|
||||
const mockResponse = {
|
||||
data: [{ report_id: 1, report_name: 'Test' }],
|
||||
};
|
||||
|
||||
SummaryReportsAPI.getInboxReports.mockResolvedValue(mockResponse);
|
||||
|
||||
await store.actions.fetchInboxSummaryReports({ commit }, params);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setUIFlags', {
|
||||
isFetchingInboxSummaryReports: true,
|
||||
});
|
||||
expect(SummaryReportsAPI.getInboxReports).toHaveBeenCalledWith(params);
|
||||
expect(commit).toHaveBeenCalledWith('setInboxSummaryReport', [
|
||||
{ reportId: 1, reportName: 'Test' },
|
||||
]);
|
||||
expect(commit).toHaveBeenCalledWith('setUIFlags', {
|
||||
isFetchingInboxSummaryReports: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
SummaryReportsAPI.getInboxReports.mockRejectedValue(
|
||||
new Error('API Error')
|
||||
);
|
||||
|
||||
await store.actions.fetchInboxSummaryReports({ commit }, {});
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setUIFlags', {
|
||||
isFetchingInboxSummaryReports: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAgentSummaryReports', () => {
|
||||
it('should fetch agent reports successfully', async () => {
|
||||
const params = { agentId: 123 };
|
||||
const mockResponse = {
|
||||
data: [{ agent_id: 123, agent_name: 'Test Agent' }],
|
||||
};
|
||||
|
||||
SummaryReportsAPI.getAgentReports.mockResolvedValue(mockResponse);
|
||||
|
||||
await store.actions.fetchAgentSummaryReports({ commit }, params);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setUIFlags', {
|
||||
isFetchingAgentSummaryReports: true,
|
||||
});
|
||||
expect(SummaryReportsAPI.getAgentReports).toHaveBeenCalledWith(params);
|
||||
expect(commit).toHaveBeenCalledWith('setAgentSummaryReport', [
|
||||
{ agentId: 123, agentName: 'Test Agent' },
|
||||
]);
|
||||
expect(commit).toHaveBeenCalledWith('setUIFlags', {
|
||||
isFetchingAgentSummaryReports: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTeamSummaryReports', () => {
|
||||
it('should fetch team reports successfully', async () => {
|
||||
const params = { teamId: 456 };
|
||||
const mockResponse = {
|
||||
data: [{ team_id: 456, team_name: 'Test Team' }],
|
||||
};
|
||||
|
||||
SummaryReportsAPI.getTeamReports.mockResolvedValue(mockResponse);
|
||||
|
||||
await store.actions.fetchTeamSummaryReports({ commit }, params);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setUIFlags', {
|
||||
isFetchingTeamSummaryReports: true,
|
||||
});
|
||||
expect(SummaryReportsAPI.getTeamReports).toHaveBeenCalledWith(params);
|
||||
expect(commit).toHaveBeenCalledWith('setTeamSummaryReport', [
|
||||
{ teamId: 456, teamName: 'Test Team' },
|
||||
]);
|
||||
expect(commit).toHaveBeenCalledWith('setUIFlags', {
|
||||
isFetchingTeamSummaryReports: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import SummaryReportsAPI from 'dashboard/api/summaryReports';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
const typeMap = {
|
||||
inbox: {
|
||||
flagKey: 'isFetchingInboxSummaryReports',
|
||||
apiMethod: 'getInboxReports',
|
||||
mutationKey: 'setInboxSummaryReport',
|
||||
},
|
||||
agent: {
|
||||
flagKey: 'isFetchingAgentSummaryReports',
|
||||
apiMethod: 'getAgentReports',
|
||||
mutationKey: 'setAgentSummaryReport',
|
||||
},
|
||||
team: {
|
||||
flagKey: 'isFetchingTeamSummaryReports',
|
||||
apiMethod: 'getTeamReports',
|
||||
mutationKey: 'setTeamSummaryReport',
|
||||
},
|
||||
};
|
||||
|
||||
async function fetchSummaryReports(type, params, { commit }) {
|
||||
const config = typeMap[type];
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
commit('setUIFlags', { [config.flagKey]: true });
|
||||
const response = await SummaryReportsAPI[config.apiMethod](params);
|
||||
commit(config.mutationKey, camelcaseKeys(response.data, { deep: true }));
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
} finally {
|
||||
commit('setUIFlags', { [config.flagKey]: false });
|
||||
}
|
||||
}
|
||||
|
||||
export const initialState = {
|
||||
inboxSummaryReports: [],
|
||||
agentSummaryReports: [],
|
||||
teamSummaryReports: [],
|
||||
uiFlags: {
|
||||
isFetchingInboxSummaryReports: false,
|
||||
isFetchingAgentSummaryReports: false,
|
||||
isFetchingTeamSummaryReports: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getInboxSummaryReports(state) {
|
||||
return state.inboxSummaryReports;
|
||||
},
|
||||
getAgentSummaryReports(state) {
|
||||
return state.agentSummaryReports;
|
||||
},
|
||||
getTeamSummaryReports(state) {
|
||||
return state.teamSummaryReports;
|
||||
},
|
||||
getUIFlags(state) {
|
||||
return state.uiFlags;
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
fetchInboxSummaryReports({ commit }, params) {
|
||||
return fetchSummaryReports('inbox', params, { commit });
|
||||
},
|
||||
|
||||
fetchAgentSummaryReports({ commit }, params) {
|
||||
return fetchSummaryReports('agent', params, { commit });
|
||||
},
|
||||
|
||||
fetchTeamSummaryReports({ commit }, params) {
|
||||
return fetchSummaryReports('team', params, { commit });
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
setInboxSummaryReport(state, data) {
|
||||
state.inboxSummaryReports = data;
|
||||
},
|
||||
setAgentSummaryReport(state, data) {
|
||||
state.agentSummaryReports = data;
|
||||
},
|
||||
setTeamSummaryReport(state, data) {
|
||||
state.teamSummaryReports = data;
|
||||
},
|
||||
setUIFlags(state, uiFlag) {
|
||||
state.uiFlags = { ...state.uiFlags, ...uiFlag };
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state: initialState,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@@ -2,6 +2,12 @@ export const getters = {
|
||||
getTeams($state) {
|
||||
return Object.values($state.records).sort((a, b) => a.id - b.id);
|
||||
},
|
||||
getTeamById: $state => id => {
|
||||
return (
|
||||
Object.values($state.records).find(record => record.id === Number(id)) ||
|
||||
{}
|
||||
);
|
||||
},
|
||||
getMyTeams($state, $getters) {
|
||||
return $getters.getTeams.filter(team => {
|
||||
const { is_member: isMember } = team;
|
||||
|
||||
@@ -3,12 +3,12 @@ class Reports::TimeFormatPresenter
|
||||
|
||||
attr_reader :seconds
|
||||
|
||||
def initialize(seconds)
|
||||
@seconds = seconds.to_i
|
||||
def initialize(seconds = nil)
|
||||
@seconds = seconds.to_i if seconds.present?
|
||||
end
|
||||
|
||||
def format
|
||||
return '--' if seconds.nil? || seconds.zero?
|
||||
return 'N/A' if seconds.nil? || seconds.zero?
|
||||
|
||||
days, remainder = seconds.divmod(86_400)
|
||||
hours, remainder = remainder.divmod(3600)
|
||||
|
||||
@@ -94,3 +94,5 @@
|
||||
premium: true
|
||||
- name: chatwoot_v4
|
||||
enabled: false
|
||||
- name: report_v4
|
||||
enabled: false
|
||||
|
||||
@@ -16,6 +16,11 @@ KillMode=mixed
|
||||
StandardInput=null
|
||||
SyslogIdentifier=%p
|
||||
|
||||
MemoryMax=1.5G
|
||||
MemoryHigh=1.4G
|
||||
MemorySwapMax=0
|
||||
OOMPolicy=stop
|
||||
|
||||
Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3/bin:/home/chatwoot/.rvm/gems/ruby-3.3.3@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.3.3/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin"
|
||||
Environment="PORT=3000"
|
||||
Environment="RAILS_ENV=production"
|
||||
|
||||
@@ -15,7 +15,8 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
def limits
|
||||
limits = {
|
||||
'conversation' => {},
|
||||
'non_web_inboxes' => {}
|
||||
'non_web_inboxes' => {},
|
||||
'captain' => @account.usage_limits[:captain]
|
||||
}
|
||||
|
||||
if default_plan?(@account)
|
||||
|
||||
@@ -32,7 +32,7 @@ class Captain::Documents::CrawlJob < ApplicationJob
|
||||
def perform_firecrawl_crawl(document)
|
||||
captain_usage_limits = document.account.usage_limits[:captain] || {}
|
||||
document_limit = captain_usage_limits[:documents] || {}
|
||||
crawl_limit = [document_limit[:available] || 10, 500].min
|
||||
crawl_limit = [document_limit[:current_available] || 10, 500].min
|
||||
|
||||
Captain::Tools::FirecrawlService
|
||||
.new
|
||||
|
||||
@@ -66,7 +66,7 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
end
|
||||
|
||||
def features_to_update
|
||||
%w[help_center campaigns team_management channel_twitter channel_facebook channel_email]
|
||||
%w[help_center campaigns team_management channel_twitter channel_facebook channel_email captain_integration]
|
||||
end
|
||||
|
||||
def subscription
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
||||
"@chatwoot/utils": "^0.0.31",
|
||||
"@chatwoot/utils": "^0.0.35",
|
||||
"@formkit/core": "^1.6.7",
|
||||
"@formkit/vue": "^1.6.7",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
|
||||
Generated
+5
-5
@@ -23,8 +23,8 @@ importers:
|
||||
specifier: 1.1.1-next
|
||||
version: 1.1.1-next
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.31
|
||||
version: 0.0.31
|
||||
specifier: ^0.0.35
|
||||
version: 0.0.35
|
||||
'@formkit/core':
|
||||
specifier: ^1.6.7
|
||||
version: 1.6.7
|
||||
@@ -404,8 +404,8 @@ packages:
|
||||
'@chatwoot/prosemirror-schema@1.1.1-next':
|
||||
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
|
||||
|
||||
'@chatwoot/utils@0.0.31':
|
||||
resolution: {integrity: sha512-15ir4Jf6q4/gIFC6KqgWZu/NEWYnaw5j2/JB+CYGzZ5a/e+rXG7nmY/sObvRqCnMW6LCS2++shlVQdTxle3CyQ==}
|
||||
'@chatwoot/utils@0.0.35':
|
||||
resolution: {integrity: sha512-uSRbd3pFp+IcEhsRtK1XGcFGFJc+X/YIwQnQrVDXsvsX3Mm7HEANj+Yz6J2clfHotajniwJwH2u5/y48+JrTyA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@codemirror/commands@6.7.0':
|
||||
@@ -5154,7 +5154,7 @@ snapshots:
|
||||
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
'@chatwoot/utils@0.0.31':
|
||||
'@chatwoot/utils@0.0.35':
|
||||
dependencies:
|
||||
date-fns: 2.29.3
|
||||
|
||||
|
||||
@@ -173,6 +173,18 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
'id' => account.id,
|
||||
'limits' => {
|
||||
'conversation' => {},
|
||||
'captain' => {
|
||||
'documents' => {
|
||||
'consumed' => 0,
|
||||
'current_available' => ChatwootApp.max_limit,
|
||||
'total_count' => ChatwootApp.max_limit
|
||||
},
|
||||
'responses' => {
|
||||
'consumed' => 0,
|
||||
'current_available' => ChatwootApp.max_limit,
|
||||
'total_count' => ChatwootApp.max_limit
|
||||
}
|
||||
},
|
||||
'non_web_inboxes' => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ RSpec.describe Captain::Documents::CrawlJob, type: :job do
|
||||
|
||||
context 'with account usage limits' do
|
||||
before do
|
||||
allow(account).to receive(:usage_limits).and_return({ captain: { documents: { available: 20 } } })
|
||||
allow(account).to receive(:usage_limits).and_return({ captain: { documents: { current_available: 20 } } })
|
||||
end
|
||||
|
||||
it 'uses FirecrawlService with the correct crawl limit' do
|
||||
@@ -35,7 +35,7 @@ RSpec.describe Captain::Documents::CrawlJob, type: :job do
|
||||
|
||||
context 'when crawl limit exceeds maximum' do
|
||||
before do
|
||||
allow(account).to receive(:usage_limits).and_return({ captain: { documents: { available: 1000 } } })
|
||||
allow(account).to receive(:usage_limits).and_return({ captain: { documents: { current_available: 1000 } } })
|
||||
end
|
||||
|
||||
it 'caps the crawl limit at 500' do
|
||||
|
||||
@@ -68,6 +68,10 @@ RSpec.describe Reports::TimeFormatPresenter do
|
||||
it 'formats single second correctly' do
|
||||
expect(described_class.new(1).format).to eq '1 second'
|
||||
end
|
||||
|
||||
it 'formats nil second correctly' do
|
||||
expect(described_class.new.format).to eq 'N/A'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user