Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
859ab6777c | ||
|
|
656ae41b24 | ||
|
|
f83415f299 | ||
|
|
0a910c3763 | ||
|
|
6a7cbcf5ba | ||
|
|
0e30e3c00a | ||
|
|
0d3b59fd9c | ||
|
|
04e747cc02 | ||
|
|
053b7774dd | ||
|
|
8eaea7c72e | ||
|
|
7ade9061a8 | ||
|
|
9eb3ee44a8 | ||
|
|
ef6ba8aabd | ||
|
|
c884cdefde | ||
|
|
c77d935e38 | ||
|
|
b686d14044 | ||
|
|
133fb1bcf6 | ||
|
|
e9e6de5690 | ||
|
|
329b749702 | ||
|
|
d8c5dda36c | ||
|
|
5ec77aca64 | ||
|
|
85324c82fa | ||
|
|
81307d5aea | ||
|
|
6f45af605c | ||
|
|
a32565d72b |
@@ -144,7 +144,7 @@ jobs:
|
||||
# Backend tests with parallelization
|
||||
backend-tests:
|
||||
<<: *defaults
|
||||
parallelism: 16
|
||||
parallelism: 18
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
@@ -350,12 +350,12 @@ jobs:
|
||||
destination: coverage
|
||||
|
||||
build:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- run:
|
||||
name: Legacy build aggregator
|
||||
command: |
|
||||
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
|
||||
<<: *defaults
|
||||
steps:
|
||||
- run:
|
||||
name: Legacy build aggregator
|
||||
command: |
|
||||
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
|
||||
@@ -276,3 +276,4 @@ AZURE_APP_SECRET=
|
||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||
|
||||
# REDIS_ALFRED_SIZE=10
|
||||
# REDIS_VELMA_SIZE=10
|
||||
|
||||
+2
-2
@@ -541,9 +541,9 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
maxminddb (0.1.22)
|
||||
meta_request (0.8.3)
|
||||
meta_request (0.8.5)
|
||||
rack-contrib (>= 1.1, < 3)
|
||||
railties (>= 3.0.0, < 8)
|
||||
railties (>= 3.0.0, < 9)
|
||||
method_source (1.1.0)
|
||||
mime-types (3.4.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
|
||||
@@ -158,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: message_type,
|
||||
status: @outgoing_echo ? :delivered : :sent,
|
||||
source_id: message_identifier,
|
||||
content: message_content,
|
||||
sender: @outgoing_echo ? nil : contact,
|
||||
@@ -166,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
}
|
||||
}
|
||||
|
||||
params[:content_attributes][:external_echo] = true if @outgoing_echo
|
||||
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
|
||||
params
|
||||
end
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
class V2::Reports::FirstResponseTimeDistributionBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account:, params:)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
build_distribution
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_distribution
|
||||
results = fetch_aggregated_counts
|
||||
map_to_channel_types(results)
|
||||
end
|
||||
|
||||
def fetch_aggregated_counts
|
||||
ReportingEvent
|
||||
.where(account_id: account.id, name: 'first_response')
|
||||
.where(range_condition)
|
||||
.group(:inbox_id)
|
||||
.select(
|
||||
:inbox_id,
|
||||
bucket_case_statements
|
||||
)
|
||||
end
|
||||
|
||||
def bucket_case_statements
|
||||
<<~SQL.squish
|
||||
COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
|
||||
COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
|
||||
COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
|
||||
COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
|
||||
COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
|
||||
SQL
|
||||
end
|
||||
|
||||
def range_condition
|
||||
range.present? ? { created_at: range } : {}
|
||||
end
|
||||
|
||||
def inbox_channel_types
|
||||
@inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
|
||||
end
|
||||
|
||||
def map_to_channel_types(results)
|
||||
results.each_with_object({}) do |row, hash|
|
||||
channel_type = inbox_channel_types[row.inbox_id]
|
||||
next unless channel_type
|
||||
|
||||
hash[channel_type] ||= empty_buckets
|
||||
hash[channel_type]['0-1h'] += row.bucket_0_1h
|
||||
hash[channel_type]['1-4h'] += row.bucket_1_4h
|
||||
hash[channel_type]['4-8h'] += row.bucket_4_8h
|
||||
hash[channel_type]['8-24h'] += row.bucket_8_24h
|
||||
hash[channel_type]['24h+'] += row.bucket_24h_plus
|
||||
end
|
||||
end
|
||||
|
||||
def empty_buckets
|
||||
{ '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
class V2::Reports::InboxLabelMatrixBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account:, params:)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
{
|
||||
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
|
||||
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
|
||||
matrix: build_matrix
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filtered_inboxes
|
||||
@filtered_inboxes ||= begin
|
||||
inboxes = account.inboxes
|
||||
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
|
||||
inboxes.order(:name).to_a
|
||||
end
|
||||
end
|
||||
|
||||
def filtered_labels
|
||||
@filtered_labels ||= begin
|
||||
labels = account.labels
|
||||
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
|
||||
labels.order(:title).to_a
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_filter
|
||||
filter = { account_id: account.id }
|
||||
filter[:created_at] = range if range.present?
|
||||
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
|
||||
filter
|
||||
end
|
||||
|
||||
def fetch_grouped_counts
|
||||
label_names = filtered_labels.map(&:title)
|
||||
return {} if label_names.empty?
|
||||
|
||||
ActsAsTaggableOn::Tagging
|
||||
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
|
||||
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
|
||||
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
|
||||
.where(tags: { name: label_names })
|
||||
.group('conversations.inbox_id', 'tags.name')
|
||||
.count
|
||||
end
|
||||
|
||||
def build_matrix
|
||||
counts = fetch_grouped_counts
|
||||
filtered_inboxes.map do |inbox|
|
||||
filtered_labels.map do |label|
|
||||
counts[[inbox.id, label.title]] || 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
class V2::Reports::OutgoingMessagesCountBuilder
|
||||
include DateRangeHelper
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
send("build_by_#{params[:group_by]}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def base_messages
|
||||
account.messages.outgoing.unscope(:order).where(created_at: range)
|
||||
end
|
||||
|
||||
def build_by_agent
|
||||
counts = base_messages
|
||||
.where(sender_type: 'User')
|
||||
.where.not(sender_id: nil)
|
||||
.group(:sender_id)
|
||||
.count
|
||||
|
||||
user_names = account.users.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |user_id, count|
|
||||
user = user_names[user_id]
|
||||
{ id: user_id, name: user&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_team
|
||||
counts = base_messages
|
||||
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
|
||||
.where.not(conversations: { team_id: nil })
|
||||
.group('conversations.team_id')
|
||||
.count
|
||||
|
||||
team_names = account.teams.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |team_id, count|
|
||||
team = team_names[team_id]
|
||||
{ id: team_id, name: team&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_inbox
|
||||
counts = base_messages
|
||||
.group(:inbox_id)
|
||||
.count
|
||||
|
||||
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |inbox_id, count|
|
||||
inbox = inbox_names[inbox_id]
|
||||
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_label
|
||||
counts = base_messages
|
||||
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
|
||||
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
|
||||
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
|
||||
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
|
||||
.group('tags.name')
|
||||
.count
|
||||
|
||||
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
|
||||
|
||||
counts.map do |label_name, count|
|
||||
label = label_ids[label_name]
|
||||
{ id: label&.id, name: label_name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -70,8 +70,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def transcript
|
||||
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
|
||||
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
|
||||
|
||||
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
|
||||
@conversation.account.increment_email_sent_count
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -35,12 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def transcript
|
||||
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
|
||||
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
|
||||
conversation,
|
||||
conversation.contact.email
|
||||
)&.deliver_later
|
||||
end
|
||||
return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
|
||||
|
||||
send_transcript_email
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -77,6 +74,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
|
||||
private
|
||||
|
||||
def send_transcript_email
|
||||
return if conversation.contact&.email.blank?
|
||||
|
||||
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
|
||||
conversation,
|
||||
conversation.contact.email
|
||||
)&.deliver_later
|
||||
conversation.account.increment_email_sent_count
|
||||
end
|
||||
|
||||
def trigger_typing_event(event)
|
||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
|
||||
end
|
||||
|
||||
@@ -62,6 +62,31 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
render json: bot_metrics
|
||||
end
|
||||
|
||||
def inbox_label_matrix
|
||||
builder = V2::Reports::InboxLabelMatrixBuilder.new(
|
||||
account: Current.account,
|
||||
params: inbox_label_matrix_params
|
||||
)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
def first_response_time_distribution
|
||||
builder = V2::Reports::FirstResponseTimeDistributionBuilder.new(
|
||||
account: Current.account,
|
||||
params: first_response_time_distribution_params
|
||||
)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze
|
||||
|
||||
def outgoing_messages_count
|
||||
return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by])
|
||||
|
||||
builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_csv(filename, template)
|
||||
@@ -139,4 +164,28 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
def conversation_metrics
|
||||
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
|
||||
end
|
||||
|
||||
def inbox_label_matrix_params
|
||||
{
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
inbox_ids: params[:inbox_ids],
|
||||
label_ids: params[:label_ids]
|
||||
}
|
||||
end
|
||||
|
||||
def first_response_time_distribution_params
|
||||
{
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
|
||||
def outgoing_messages_count_params
|
||||
{
|
||||
group_by: params[:group_by],
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -42,7 +42,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
|
||||
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
|
||||
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
|
||||
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
|
||||
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
|
||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@ const dialogRef = ref(null);
|
||||
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const meta = useMapGetter('captainResponses/getMeta');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const totalCount = computed(() => meta.value.totalCount || 0);
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
@@ -37,7 +39,7 @@ defineExpose({ dialogRef });
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')"
|
||||
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
|
||||
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
|
||||
@@ -3,12 +3,14 @@ import { onMounted, computed, ref, toRefs } from 'vue';
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
import { provideMessageContext } from './provider.js';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
MESSAGE_TYPES,
|
||||
@@ -139,6 +141,8 @@ const showBackgroundHighlight = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
@@ -162,6 +166,10 @@ const variant = computed(() => {
|
||||
if (props.contentAttributes?.isUnsupported)
|
||||
return MESSAGE_VARIANTS.UNSUPPORTED;
|
||||
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
return MESSAGE_VARIANTS.AGENT;
|
||||
}
|
||||
|
||||
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
|
||||
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
|
||||
return MESSAGE_VARIANTS.BOT;
|
||||
@@ -424,6 +432,18 @@ function handleReplyTo() {
|
||||
}
|
||||
|
||||
const avatarInfo = computed(() => {
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
const { name, avatar_url, channel_type, medium } = inbox.value;
|
||||
const iconName = avatar_url
|
||||
? null
|
||||
: getInboxIconByType(channel_type, medium);
|
||||
return {
|
||||
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
|
||||
src: avatar_url || '',
|
||||
iconName,
|
||||
};
|
||||
}
|
||||
|
||||
// If no sender, return bot info
|
||||
if (!props.sender) {
|
||||
return {
|
||||
@@ -451,6 +471,9 @@ const avatarInfo = computed(() => {
|
||||
});
|
||||
|
||||
const avatarTooltip = computed(() => {
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
return t('CONVERSATION.NATIVE_APP_ADVISORY');
|
||||
}
|
||||
if (avatarInfo.value.name === '') return '';
|
||||
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
||||
});
|
||||
@@ -484,7 +507,7 @@ provideMessageContext({
|
||||
<div
|
||||
v-if="shouldRenderMessage"
|
||||
:id="`message${props.id}`"
|
||||
class="flex mb-2 w-full message-bubble-container"
|
||||
class="flex w-full mb-2 message-bubble-container"
|
||||
:data-message-id="props.id"
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { emitter } from 'shared/helpers/mitt';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
||||
|
||||
@@ -80,7 +81,7 @@ const replyToPreview = computed(() => {
|
||||
|
||||
const { content, attachments } = inReplyTo.value;
|
||||
|
||||
if (content) return content;
|
||||
if (content) return new MessageFormatter(content).formattedMessage;
|
||||
if (attachments?.length) {
|
||||
const firstAttachment = attachments[0];
|
||||
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
|
||||
@@ -107,9 +108,10 @@ const replyToPreview = computed(() => {
|
||||
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
|
||||
@click="scrollToMessage"
|
||||
>
|
||||
<span class="break-all line-clamp-2">
|
||||
{{ replyToPreview }}
|
||||
</span>
|
||||
<div
|
||||
v-dompurify-html="replyToPreview"
|
||||
class="prose prose-bubble line-clamp-2"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
<MessageMeta
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
getActiveDateRange,
|
||||
moveCalendarDate,
|
||||
DATE_RANGE_TYPES,
|
||||
CALENDAR_TYPES,
|
||||
CALENDAR_PERIODS,
|
||||
isNavigableRange,
|
||||
getRangeAtOffset,
|
||||
} from './helpers/DatePickerHelper';
|
||||
import {
|
||||
isValid,
|
||||
@@ -13,14 +16,14 @@ import {
|
||||
subDays,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
isBefore,
|
||||
subMonths,
|
||||
addMonths,
|
||||
isSameMonth,
|
||||
differenceInCalendarMonths,
|
||||
differenceInCalendarWeeks,
|
||||
setMonth,
|
||||
setYear,
|
||||
isAfter,
|
||||
getWeek,
|
||||
} from 'date-fns';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import DatePickerButton from './components/DatePickerButton.vue';
|
||||
@@ -32,98 +35,187 @@ import CalendarWeek from './components/CalendarWeek.vue';
|
||||
import CalendarFooter from './components/CalendarFooter.vue';
|
||||
|
||||
const emit = defineEmits(['dateRangeChanged']);
|
||||
const { LAST_7_DAYS, LAST_30_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
|
||||
const { t } = useI18n();
|
||||
|
||||
const dateRange = defineModel('dateRange', {
|
||||
type: Array,
|
||||
default: undefined,
|
||||
});
|
||||
|
||||
const rangeType = defineModel('rangeType', {
|
||||
type: String,
|
||||
default: undefined,
|
||||
});
|
||||
const { LAST_7_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
|
||||
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
|
||||
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
|
||||
|
||||
const showDatePicker = ref(false);
|
||||
const calendarViews = ref({ start: WEEK, end: WEEK });
|
||||
const currentDate = ref(new Date());
|
||||
const selectedStartDate = ref(startOfDay(subDays(currentDate.value, 6))); // LAST_7_DAYS
|
||||
const selectedEndDate = ref(endOfDay(currentDate.value));
|
||||
// Setting the start and end calendar
|
||||
const startCurrentDate = ref(startOfDay(selectedStartDate.value));
|
||||
|
||||
// Use dates from v-model if provided, otherwise default to last 7 days
|
||||
const selectedStartDate = ref(
|
||||
dateRange.value?.[0]
|
||||
? startOfDay(dateRange.value[0])
|
||||
: startOfDay(subDays(currentDate.value, 6)) // LAST_7_DAYS
|
||||
);
|
||||
const selectedEndDate = ref(
|
||||
dateRange.value?.[1]
|
||||
? endOfDay(dateRange.value[1])
|
||||
: endOfDay(currentDate.value)
|
||||
);
|
||||
// Calendar month positioning (left and right calendars)
|
||||
// These control which months are displayed in the dual calendar view
|
||||
const startCurrentDate = ref(startOfMonth(selectedStartDate.value));
|
||||
const endCurrentDate = ref(
|
||||
isSameMonth(selectedStartDate.value, selectedEndDate.value)
|
||||
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Moves to the start of the next month if dates are in the same month (Mounted case LAST_7_DAYS)
|
||||
: startOfMonth(selectedEndDate.value) // Always shows the month of the end date starting from the first (Mounted case LAST_7_DAYS)
|
||||
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Same month: show next month on right (e.g., Jan 25-31 shows Jan + Feb)
|
||||
: startOfMonth(selectedEndDate.value) // Different months: show end month on right (e.g., Dec 5 - Jan 3 shows Dec + Jan)
|
||||
);
|
||||
const selectingEndDate = ref(false);
|
||||
const selectedRange = ref(LAST_7_DAYS);
|
||||
const selectedRange = ref(rangeType.value || LAST_7_DAYS);
|
||||
const hoveredEndDate = ref(null);
|
||||
const monthOffset = ref(0);
|
||||
|
||||
const showMonthNavigation = computed(() =>
|
||||
isNavigableRange(selectedRange.value)
|
||||
);
|
||||
const canNavigateNext = computed(() => {
|
||||
if (!isNavigableRange(selectedRange.value)) return false;
|
||||
// Compare selected start to the current period's start to determine if we're in the past
|
||||
const currentRange = getActiveDateRange(
|
||||
selectedRange.value,
|
||||
currentDate.value
|
||||
);
|
||||
return selectedStartDate.value < currentRange.start;
|
||||
});
|
||||
|
||||
const navigationLabel = computed(() => {
|
||||
const range = selectedRange.value;
|
||||
if (range === DATE_RANGE_TYPES.MONTH_TO_DATE) {
|
||||
return new Intl.DateTimeFormat(navigator.language, {
|
||||
month: 'long',
|
||||
}).format(selectedStartDate.value);
|
||||
}
|
||||
if (range === DATE_RANGE_TYPES.THIS_WEEK) {
|
||||
const currentWeekRange = getActiveDateRange(range, currentDate.value);
|
||||
const isCurrentWeek =
|
||||
selectedStartDate.value.getTime() === currentWeekRange.start.getTime();
|
||||
if (isCurrentWeek) return null;
|
||||
const weekNumber = getWeek(selectedStartDate.value, { weekStartsOn: 1 });
|
||||
return t('DATE_PICKER.WEEK_NUMBER', { weekNumber });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const manualStartDate = ref(selectedStartDate.value);
|
||||
const manualEndDate = ref(selectedEndDate.value);
|
||||
|
||||
// Watcher will set the start and end dates based on the selected range
|
||||
watch(selectedRange, newRange => {
|
||||
if (newRange !== CUSTOM_RANGE) {
|
||||
// If selecting a range other than last 7 days or last 30 days, set the start and end dates to the selected start and end dates
|
||||
// If selecting last 7 days or last 30 days is, set the start date to the selected start date
|
||||
// and the end date to one month ahead of the start date if the start date and end date are in the same month
|
||||
// Otherwise set the end date to the selected end date
|
||||
const isLastSevenOrThirtyDays =
|
||||
newRange === LAST_7_DAYS || newRange === LAST_30_DAYS;
|
||||
startCurrentDate.value = selectedStartDate.value;
|
||||
endCurrentDate.value =
|
||||
isLastSevenOrThirtyDays &&
|
||||
isSameMonth(selectedStartDate.value, selectedEndDate.value)
|
||||
? startOfMonth(addMonths(selectedStartDate.value, 1))
|
||||
: selectedEndDate.value;
|
||||
selectingEndDate.value = false;
|
||||
} else if (!selectingEndDate.value) {
|
||||
// If selecting a custom range and not selecting an end date, set the start date to the selected start date
|
||||
startCurrentDate.value = startOfDay(currentDate.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Watcher will set the input values based on the selected start and end dates
|
||||
// Watcher 1: Sync v-model props from parent component
|
||||
// Handles: URL params, parent component updates, rangeType changes
|
||||
watch(
|
||||
[selectedStartDate, selectedEndDate],
|
||||
([newStart, newEnd]) => {
|
||||
if (isValid(newStart)) {
|
||||
manualStartDate.value = newStart;
|
||||
} else {
|
||||
manualStartDate.value = selectedStartDate.value;
|
||||
[rangeType, dateRange],
|
||||
([newRangeType, newDateRange]) => {
|
||||
if (newRangeType && newRangeType !== selectedRange.value) {
|
||||
selectedRange.value = newRangeType;
|
||||
monthOffset.value = 0;
|
||||
|
||||
// If rangeType changes without dateRange, recompute dates from the range
|
||||
if (!newDateRange && newRangeType !== CUSTOM_RANGE) {
|
||||
const activeDates = getActiveDateRange(newRangeType, currentDate.value);
|
||||
if (activeDates) {
|
||||
selectedStartDate.value = startOfDay(activeDates.start);
|
||||
selectedEndDate.value = endOfDay(activeDates.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isValid(newEnd)) {
|
||||
manualEndDate.value = newEnd;
|
||||
} else {
|
||||
manualEndDate.value = selectedEndDate.value;
|
||||
// When parent provides new dateRange (e.g., from URL params)
|
||||
// Skip if navigating with arrows — offset controls dates in that case
|
||||
if (newDateRange?.[0] && newDateRange?.[1] && monthOffset.value === 0) {
|
||||
selectedStartDate.value = startOfDay(newDateRange[0]);
|
||||
selectedEndDate.value = endOfDay(newDateRange[1]);
|
||||
|
||||
// Update calendar to show the months of the new date range
|
||||
startCurrentDate.value = startOfMonth(newDateRange[0]);
|
||||
endCurrentDate.value = isSameMonth(newDateRange[0], newDateRange[1])
|
||||
? startOfMonth(addMonths(newDateRange[1], 1))
|
||||
: startOfMonth(newDateRange[1]);
|
||||
|
||||
// Recalculate offset so arrow navigation is relative to restored range
|
||||
// TODO: When offset resolves to 0 (current period), the end date may be
|
||||
// stale if the URL was saved on a previous day. "This month" / "This week"
|
||||
// should show up-to-today dates for the current period. For now, the stale
|
||||
// end date is shown until the user clicks an arrow or re-selects the range.
|
||||
if (isNavigableRange(selectedRange.value)) {
|
||||
const current = getActiveDateRange(
|
||||
selectedRange.value,
|
||||
currentDate.value
|
||||
);
|
||||
if (selectedRange.value === DATE_RANGE_TYPES.THIS_WEEK) {
|
||||
monthOffset.value = differenceInCalendarWeeks(
|
||||
newDateRange[0],
|
||||
current.start,
|
||||
{ weekStartsOn: 1 }
|
||||
);
|
||||
} else {
|
||||
monthOffset.value = differenceInCalendarMonths(
|
||||
newDateRange[0],
|
||||
current.start
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Watcher to ensure dates are always in logical order
|
||||
// This watch is will ensure that the start date is always before the end date
|
||||
// Watcher 2: Keep manual input fields in sync with selected dates
|
||||
// Updates the input field values when dates change programmatically
|
||||
watch(
|
||||
[startCurrentDate, endCurrentDate],
|
||||
([newStart, newEnd], [oldStart, oldEnd]) => {
|
||||
const monthDifference = differenceInCalendarMonths(newEnd, newStart);
|
||||
|
||||
if (newStart !== oldStart) {
|
||||
if (isAfter(newStart, newEnd) || monthDifference === 0) {
|
||||
// Adjust the end date forward if the start date is adjusted and is after the end date or in the same month
|
||||
endCurrentDate.value = addMonths(newStart, 1);
|
||||
}
|
||||
}
|
||||
if (newEnd !== oldEnd) {
|
||||
if (isBefore(newEnd, newStart) || monthDifference === 0) {
|
||||
// Adjust the start date backward if the end date is adjusted and is before the start date or in the same month
|
||||
startCurrentDate.value = subMonths(newEnd, 1);
|
||||
}
|
||||
}
|
||||
[selectedStartDate, selectedEndDate],
|
||||
([newStart, newEnd]) => {
|
||||
manualStartDate.value = isValid(newStart)
|
||||
? newStart
|
||||
: selectedStartDate.value;
|
||||
manualEndDate.value = isValid(newEnd) ? newEnd : selectedEndDate.value;
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const setDateRange = range => {
|
||||
selectedRange.value = range.value;
|
||||
monthOffset.value = 0;
|
||||
const { start, end } = getActiveDateRange(range.value, currentDate.value);
|
||||
selectedStartDate.value = start;
|
||||
selectedEndDate.value = end;
|
||||
|
||||
// Position calendar to show the months of the selected range
|
||||
startCurrentDate.value = startOfMonth(start);
|
||||
endCurrentDate.value = isSameMonth(start, end)
|
||||
? startOfMonth(addMonths(start, 1))
|
||||
: startOfMonth(end);
|
||||
};
|
||||
|
||||
const navigateMonth = direction => {
|
||||
monthOffset.value += direction === 'prev' ? -1 : 1;
|
||||
if (monthOffset.value > 0) monthOffset.value = 0;
|
||||
|
||||
const { start, end } = getRangeAtOffset(
|
||||
selectedRange.value,
|
||||
monthOffset.value,
|
||||
currentDate.value
|
||||
);
|
||||
selectedStartDate.value = start;
|
||||
selectedEndDate.value = end;
|
||||
|
||||
startCurrentDate.value = startOfMonth(start);
|
||||
endCurrentDate.value = isSameMonth(start, end)
|
||||
? startOfMonth(addMonths(start, 1))
|
||||
: startOfMonth(end);
|
||||
|
||||
emit('dateRangeChanged', [start, end, selectedRange.value]);
|
||||
};
|
||||
|
||||
const moveCalendar = (calendar, direction, period = MONTH) => {
|
||||
@@ -134,12 +226,27 @@ const moveCalendar = (calendar, direction, period = MONTH) => {
|
||||
direction,
|
||||
period
|
||||
);
|
||||
startCurrentDate.value = start;
|
||||
endCurrentDate.value = end;
|
||||
|
||||
// Prevent calendar months from overlapping
|
||||
const monthDiff = differenceInCalendarMonths(end, start);
|
||||
if (monthDiff === 0) {
|
||||
// If they would be the same month, adjust the other calendar
|
||||
if (calendar === START_CALENDAR) {
|
||||
endCurrentDate.value = addMonths(start, 1);
|
||||
startCurrentDate.value = start;
|
||||
} else {
|
||||
startCurrentDate.value = subMonths(end, 1);
|
||||
endCurrentDate.value = end;
|
||||
}
|
||||
} else {
|
||||
startCurrentDate.value = start;
|
||||
endCurrentDate.value = end;
|
||||
}
|
||||
};
|
||||
|
||||
const selectDate = day => {
|
||||
selectedRange.value = CUSTOM_RANGE;
|
||||
monthOffset.value = 0;
|
||||
if (!selectingEndDate.value || day < selectedStartDate.value) {
|
||||
selectedStartDate.value = day;
|
||||
selectedEndDate.value = null;
|
||||
@@ -175,10 +282,10 @@ const openCalendar = (index, calendarType, period = MONTH) => {
|
||||
const updateManualInput = (newDate, calendarType) => {
|
||||
if (calendarType === START_CALENDAR) {
|
||||
selectedStartDate.value = newDate;
|
||||
startCurrentDate.value = newDate;
|
||||
startCurrentDate.value = startOfMonth(newDate);
|
||||
} else {
|
||||
selectedEndDate.value = newDate;
|
||||
endCurrentDate.value = newDate;
|
||||
endCurrentDate.value = startOfMonth(newDate);
|
||||
}
|
||||
selectingEndDate.value = false;
|
||||
};
|
||||
@@ -188,13 +295,22 @@ const handleManualInputError = message => {
|
||||
};
|
||||
|
||||
const resetDatePicker = () => {
|
||||
startCurrentDate.value = startOfDay(currentDate.value); // Resets to today at start of the day
|
||||
endCurrentDate.value = addMonths(startOfDay(currentDate.value), 1); // Resets to one month ahead
|
||||
selectedStartDate.value = startOfDay(subDays(currentDate.value, 6));
|
||||
selectedEndDate.value = endOfDay(currentDate.value);
|
||||
// Calculate Last 7 days from today
|
||||
const startDate = startOfDay(subDays(currentDate.value, 6));
|
||||
const endDate = endOfDay(currentDate.value);
|
||||
|
||||
selectedStartDate.value = startDate;
|
||||
selectedEndDate.value = endDate;
|
||||
|
||||
// Position calendar to show the months of Last 7 days
|
||||
// Example: If today is Feb 5, Last 7 days = Jan 30 - Feb 5, so show Jan + Feb
|
||||
startCurrentDate.value = startOfMonth(startDate);
|
||||
endCurrentDate.value = isSameMonth(startDate, endDate)
|
||||
? startOfMonth(addMonths(startDate, 1))
|
||||
: startOfMonth(endDate);
|
||||
selectingEndDate.value = false;
|
||||
selectedRange.value = LAST_7_DAYS;
|
||||
// Reset view modes if they are being used to toggle between different calendar views
|
||||
monthOffset.value = 0;
|
||||
calendarViews.value = { start: WEEK, end: WEEK };
|
||||
};
|
||||
|
||||
@@ -203,26 +319,58 @@ const emitDateRange = () => {
|
||||
useAlert('Please select a valid time range');
|
||||
} else {
|
||||
showDatePicker.value = false;
|
||||
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
|
||||
emit('dateRangeChanged', [
|
||||
selectedStartDate.value,
|
||||
selectedEndDate.value,
|
||||
selectedRange.value,
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
// Called when picker opens - positions calendar to show selected date range
|
||||
// Fixes issue where calendar showed wrong months when loaded from URL params
|
||||
const initializeCalendarMonths = () => {
|
||||
if (selectedStartDate.value && selectedEndDate.value) {
|
||||
startCurrentDate.value = startOfMonth(selectedStartDate.value);
|
||||
endCurrentDate.value = isSameMonth(
|
||||
selectedStartDate.value,
|
||||
selectedEndDate.value
|
||||
)
|
||||
? startOfMonth(addMonths(selectedEndDate.value, 1))
|
||||
: startOfMonth(selectedEndDate.value);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDatePicker = () => {
|
||||
showDatePicker.value = !showDatePicker.value;
|
||||
if (showDatePicker.value) initializeCalendarMonths();
|
||||
};
|
||||
|
||||
const closeDatePicker = () => {
|
||||
showDatePicker.value = false;
|
||||
if (isValid(selectedStartDate.value) && isValid(selectedEndDate.value)) {
|
||||
emitDateRange();
|
||||
} else {
|
||||
showDatePicker.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
|
||||
<div class="relative flex-shrink-0 font-inter">
|
||||
<DatePickerButton
|
||||
:selected-start-date="selectedStartDate"
|
||||
:selected-end-date="selectedEndDate"
|
||||
:selected-range="selectedRange"
|
||||
@open="showDatePicker = !showDatePicker"
|
||||
:show-month-navigation="showMonthNavigation"
|
||||
:can-navigate-next="canNavigateNext"
|
||||
:navigation-label="navigationLabel"
|
||||
@open="toggleDatePicker"
|
||||
@navigate-month="navigateMonth"
|
||||
/>
|
||||
<div
|
||||
v-if="showDatePicker"
|
||||
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
|
||||
v-on-clickaway="closeDatePicker"
|
||||
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
|
||||
>
|
||||
<CalendarDateRange
|
||||
:selected-range="selectedRange"
|
||||
|
||||
@@ -46,13 +46,13 @@ const onClickSetView = (type, mode) => {
|
||||
xs
|
||||
icon="i-lucide-chevron-left"
|
||||
class="rtl:rotate-180"
|
||||
@click="onClickPrev(calendarType)"
|
||||
@click.stop="onClickPrev(calendarType)"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="firstButtonLabel"
|
||||
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
|
||||
@click="onClickSetView(calendarType, viewMode)"
|
||||
@click.stop="onClickSetView(calendarType, viewMode)"
|
||||
>
|
||||
{{ firstButtonLabel }}
|
||||
</button>
|
||||
@@ -60,7 +60,7 @@ const onClickSetView = (type, mode) => {
|
||||
v-if="buttonLabel"
|
||||
class="p-0 text-sm font-medium text-center text-n-slate-12"
|
||||
:class="{ 'hover:text-n-brand': viewMode }"
|
||||
@click="onClickSetView(calendarType, YEAR)"
|
||||
@click.stop="onClickSetView(calendarType, YEAR)"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
@@ -71,7 +71,7 @@ const onClickSetView = (type, mode) => {
|
||||
xs
|
||||
icon="i-lucide-chevron-right"
|
||||
class="rtl:rotate-180"
|
||||
@click="onClickNext(calendarType)"
|
||||
@click.stop="onClickNext(calendarType)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+15
-14
@@ -18,24 +18,25 @@ const setDateRange = range => {
|
||||
<template>
|
||||
<div class="w-[200px] flex flex-col items-start">
|
||||
<h4
|
||||
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12"
|
||||
class="w-full px-5 py-4 text-xs font-bold capitalize text-start text-n-slate-10"
|
||||
>
|
||||
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
|
||||
</h4>
|
||||
<div class="flex flex-col items-start w-full">
|
||||
<button
|
||||
v-for="range in dateRanges"
|
||||
:key="range.label"
|
||||
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
|
||||
:class="
|
||||
range.value === selectedRange
|
||||
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
@click="setDateRange(range)"
|
||||
>
|
||||
{{ $t(range.label) }}
|
||||
</button>
|
||||
<template v-for="range in dateRanges" :key="range.label">
|
||||
<div v-if="range.separator" class="w-full border-t border-n-strong" />
|
||||
<button
|
||||
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
|
||||
:class="
|
||||
range.value === selectedRange
|
||||
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
@click="setDateRange(range)"
|
||||
>
|
||||
{{ $t(range.label) }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,7 +23,6 @@ const onClickApply = () => {
|
||||
/>
|
||||
<NextButton
|
||||
sm
|
||||
ghost
|
||||
:label="$t('DATE_PICKER.APPLY_BUTTON')"
|
||||
@click="onClickApply"
|
||||
/>
|
||||
|
||||
@@ -78,7 +78,7 @@ const selectMonth = index => {
|
||||
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
|
||||
index !== activeMonthIndex,
|
||||
}"
|
||||
@click="selectMonth(index)"
|
||||
@click.stop="selectMonth(index)"
|
||||
>
|
||||
{{ month }}
|
||||
</button>
|
||||
|
||||
@@ -77,7 +77,7 @@ const selectYear = year => {
|
||||
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear,
|
||||
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear,
|
||||
}"
|
||||
@click="selectYear(year)"
|
||||
@click.stop="selectYear(year)"
|
||||
>
|
||||
{{ year }}
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed } from 'vue';
|
||||
import { dateRanges } from '../helpers/DatePickerHelper';
|
||||
import { format, isSameYear, isValid } from 'date-fns';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedStartDate: Date,
|
||||
@@ -10,9 +12,21 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showMonthNavigation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
canNavigateNext: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
navigationLabel: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['open']);
|
||||
const emit = defineEmits(['open', 'navigateMonth']);
|
||||
|
||||
const formatDateRange = computed(() => {
|
||||
const startDate = props.selectedStartDate;
|
||||
@@ -22,19 +36,15 @@ const formatDateRange = computed(() => {
|
||||
return 'Select a date range';
|
||||
}
|
||||
|
||||
const formatString = isSameYear(startDate, endDate)
|
||||
? 'MMM d' // Same year: "Apr 1"
|
||||
: 'MMM d yyyy'; // Different years: "Apr 1 2025"
|
||||
const crossesYears = !isSameYear(startDate, endDate);
|
||||
|
||||
if (isSameYear(startDate, new Date()) && isSameYear(endDate, new Date())) {
|
||||
// Both dates are in the current year
|
||||
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
|
||||
// Always show years when crossing year boundaries
|
||||
if (crossesYears) {
|
||||
return `${format(startDate, 'MMM d, yyyy')} - ${format(endDate, 'MMM d, yyyy')}`;
|
||||
}
|
||||
// At least one date is not in the current year
|
||||
return `${format(startDate, formatString)} - ${format(
|
||||
endDate,
|
||||
formatString
|
||||
)}`;
|
||||
|
||||
// For same year, always show the year for clarity
|
||||
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`;
|
||||
});
|
||||
|
||||
const activeDateRange = computed(
|
||||
@@ -47,17 +57,46 @@ const openDatePicker = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1"
|
||||
@click="openDatePicker"
|
||||
>
|
||||
<fluent-icon class="text-n-slate-12" icon="calendar" size="16" />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t(activeDateRange) }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-n-slate-11">
|
||||
{{ formatDateRange }}
|
||||
</span>
|
||||
<fluent-icon class="text-n-slate-12" icon="chevron-down" size="14" />
|
||||
</button>
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button
|
||||
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1 flex-shrink-0"
|
||||
@click="openDatePicker"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-calendar-range"
|
||||
class="text-n-slate-11 size-3.5 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12 truncate">
|
||||
{{ navigationLabel || $t(activeDateRange) }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-n-slate-11 truncate">
|
||||
{{ formatDateRange }}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="text-n-slate-12 size-4 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<NextButton
|
||||
v-if="showMonthNavigation"
|
||||
v-tooltip.top="$t('DATE_PICKER.PREVIOUS_PERIOD')"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
icon="i-lucide-chevron-left"
|
||||
class="rtl:rotate-180"
|
||||
@click="emit('navigateMonth', 'prev')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showMonthNavigation"
|
||||
v-tooltip.top="$t('DATE_PICKER.NEXT_PERIOD')"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
icon="i-lucide-chevron-right"
|
||||
class="rtl:rotate-180"
|
||||
:disabled="!canNavigateNext"
|
||||
@click="emit('navigateMonth', 'next')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
isSameMonth,
|
||||
format,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addWeeks,
|
||||
addDays,
|
||||
eachDayOfInterval,
|
||||
endOfMonth,
|
||||
@@ -34,13 +36,27 @@ export const dateRanges = [
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
|
||||
value: 'last3months',
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
|
||||
value: 'last6months',
|
||||
},
|
||||
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_YEAR', value: 'lastYear' },
|
||||
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE', value: 'custom' },
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.THIS_WEEK',
|
||||
value: 'thisWeek',
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.MONTH_TO_DATE',
|
||||
value: 'monthToDate',
|
||||
},
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE',
|
||||
value: 'custom',
|
||||
separator: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const DATE_RANGE_TYPES = {
|
||||
@@ -49,6 +65,8 @@ export const DATE_RANGE_TYPES = {
|
||||
LAST_3_MONTHS: 'last3months',
|
||||
LAST_6_MONTHS: 'last6months',
|
||||
LAST_YEAR: 'lastYear',
|
||||
THIS_WEEK: 'thisWeek',
|
||||
MONTH_TO_DATE: 'monthToDate',
|
||||
CUSTOM_RANGE: 'custom',
|
||||
};
|
||||
|
||||
@@ -210,6 +228,14 @@ export const getActiveDateRange = (range, currentDate) => {
|
||||
start: startOfDay(subMonths(currentDate, 12)),
|
||||
end: endOfDay(currentDate),
|
||||
}),
|
||||
thisWeek: () => ({
|
||||
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: 1 })),
|
||||
end: endOfDay(currentDate),
|
||||
}),
|
||||
monthToDate: () => ({
|
||||
start: startOfDay(startOfMonth(currentDate)),
|
||||
end: endOfDay(currentDate),
|
||||
}),
|
||||
custom: () => ({ start: currentDate, end: currentDate }),
|
||||
};
|
||||
|
||||
@@ -217,3 +243,48 @@ export const getActiveDateRange = (range, currentDate) => {
|
||||
ranges[range] || (() => ({ start: currentDate, end: currentDate }))
|
||||
)();
|
||||
};
|
||||
|
||||
export const isNavigableRange = rangeType =>
|
||||
rangeType === DATE_RANGE_TYPES.MONTH_TO_DATE ||
|
||||
rangeType === DATE_RANGE_TYPES.THIS_WEEK;
|
||||
|
||||
const WEEK_START = 1; // Monday
|
||||
|
||||
const getWeekRangeAtOffset = (offset, currentDate) => {
|
||||
if (offset === 0) {
|
||||
return {
|
||||
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: WEEK_START })),
|
||||
end: endOfDay(currentDate),
|
||||
};
|
||||
}
|
||||
const targetWeek = addWeeks(currentDate, offset);
|
||||
return {
|
||||
start: startOfDay(startOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
|
||||
end: endOfDay(endOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
|
||||
};
|
||||
};
|
||||
|
||||
const getMonthRangeAtOffset = (offset, currentDate) => {
|
||||
if (offset === 0) {
|
||||
return {
|
||||
start: startOfDay(startOfMonth(currentDate)),
|
||||
end: endOfDay(currentDate),
|
||||
};
|
||||
}
|
||||
const targetMonth = addMonths(currentDate, offset);
|
||||
return {
|
||||
start: startOfDay(startOfMonth(targetMonth)),
|
||||
end: endOfDay(endOfMonth(targetMonth)),
|
||||
};
|
||||
};
|
||||
|
||||
export const getRangeAtOffset = (
|
||||
rangeType,
|
||||
offset,
|
||||
currentDate = new Date()
|
||||
) => {
|
||||
if (rangeType === DATE_RANGE_TYPES.THIS_WEEK) {
|
||||
return getWeekRangeAtOffset(offset, currentDate);
|
||||
}
|
||||
return getMonthRangeAtOffset(offset, currentDate);
|
||||
};
|
||||
|
||||
@@ -34,8 +34,8 @@ const value = defineModel({
|
||||
<input
|
||||
v-model="value"
|
||||
:placeholder="inputPlaceholder"
|
||||
type="text"
|
||||
class="w-full mb-0 text-sm !outline-0 bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
|
||||
type="search"
|
||||
class="w-full mb-0 text-sm !outline-0 !outline-none bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
|
||||
/>
|
||||
</div>
|
||||
<!-- Clear filter button -->
|
||||
|
||||
@@ -127,6 +127,7 @@ const validateSingleAction = action => {
|
||||
'resolve_conversation',
|
||||
'remove_assigned_team',
|
||||
'open_conversation',
|
||||
'pending_conversation',
|
||||
];
|
||||
|
||||
if (
|
||||
|
||||
@@ -150,7 +150,8 @@
|
||||
"ADD_PRIVATE_NOTE": "Add a Private Note",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "Open conversation"
|
||||
"OPEN_CONVERSATION": "Open conversation",
|
||||
"PENDING_CONVERSATION": "Mark conversation as pending"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"INCOMING": "Incoming Message",
|
||||
|
||||
@@ -253,6 +253,8 @@
|
||||
"MESSAGE_ERROR": "Unable to send this message, please try again later",
|
||||
"SENT_BY": "Sent by:",
|
||||
"BOT": "Bot",
|
||||
"NATIVE_APP": "Native app",
|
||||
"NATIVE_APP_ADVISORY": "This message was sent from the native app. Reply from Chatwoot to maintain the message window.",
|
||||
"SEND_FAILED": "Couldn't send message! Try again",
|
||||
"TRY_AGAIN": "retry",
|
||||
"ASSIGNMENT": {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"DATE_PICKER": {
|
||||
"PREVIOUS_PERIOD": "Previous period",
|
||||
"NEXT_PERIOD": "Next period",
|
||||
"WEEK_NUMBER": "Week #{weekNumber}",
|
||||
"APPLY_BUTTON": "Apply",
|
||||
"CLEAR_BUTTON": "Clear",
|
||||
"DATE_RANGE_INPUT": {
|
||||
@@ -13,6 +16,8 @@
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
"LAST_YEAR": "Last year",
|
||||
"THIS_WEEK": "This week",
|
||||
"MONTH_TO_DATE": "This month",
|
||||
"CUSTOM_RANGE": "Custom date range"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,11 +128,16 @@
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Agent",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"AGENTS": "Search agents"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -201,6 +206,11 @@
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Label",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"LABELS": "Search labels"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -271,6 +281,11 @@
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"INBOXES": "Search inboxes"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -334,11 +349,19 @@
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Team",
|
||||
"FILTERS": {
|
||||
"ADD_FILTER": "Add filter",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"NO_FILTER": "No filters available",
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"TEAMS": "Search teams"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
|
||||
@@ -116,6 +116,10 @@ export const AUTOMATIONS = {
|
||||
key: 'open_conversation',
|
||||
name: 'OPEN_CONVERSATION',
|
||||
},
|
||||
{
|
||||
key: 'pending_conversation',
|
||||
name: 'PENDING_CONVERSATION',
|
||||
},
|
||||
{
|
||||
key: 'resolve_conversation',
|
||||
name: 'RESOLVE_CONVERSATION',
|
||||
@@ -232,6 +236,10 @@ export const AUTOMATIONS = {
|
||||
key: 'snooze_conversation',
|
||||
name: 'SNOOZE_CONVERSATION',
|
||||
},
|
||||
{
|
||||
key: 'pending_conversation',
|
||||
name: 'PENDING_CONVERSATION',
|
||||
},
|
||||
{
|
||||
key: 'resolve_conversation',
|
||||
name: 'RESOLVE_CONVERSATION',
|
||||
@@ -360,6 +368,10 @@ export const AUTOMATIONS = {
|
||||
key: 'snooze_conversation',
|
||||
name: 'SNOOZE_CONVERSATION',
|
||||
},
|
||||
{
|
||||
key: 'pending_conversation',
|
||||
name: 'PENDING_CONVERSATION',
|
||||
},
|
||||
{
|
||||
key: 'resolve_conversation',
|
||||
name: 'RESOLVE_CONVERSATION',
|
||||
@@ -482,6 +494,10 @@ export const AUTOMATIONS = {
|
||||
key: 'snooze_conversation',
|
||||
name: 'SNOOZE_CONVERSATION',
|
||||
},
|
||||
{
|
||||
key: 'pending_conversation',
|
||||
name: 'PENDING_CONVERSATION',
|
||||
},
|
||||
{
|
||||
key: 'send_webhook_event',
|
||||
name: 'SEND_WEBHOOK_EVENT',
|
||||
@@ -668,6 +684,11 @@ export const AUTOMATION_ACTION_TYPES = [
|
||||
label: 'OPEN_CONVERSATION',
|
||||
inputType: null,
|
||||
},
|
||||
{
|
||||
key: 'pending_conversation',
|
||||
label: 'PENDING_CONVERSATION',
|
||||
inputType: null,
|
||||
},
|
||||
{
|
||||
key: 'send_webhook_event',
|
||||
label: 'SEND_WEBHOOK_EVENT',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import BotMetrics from './components/BotMetrics.vue';
|
||||
import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import ReportFilters from './components/ReportFilters.vue';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import ReportContainer from './ReportContainer.vue';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
@@ -12,7 +12,7 @@ export default {
|
||||
components: {
|
||||
BotMetrics,
|
||||
ReportHeader,
|
||||
ReportFilterSelector,
|
||||
ReportFilters,
|
||||
ReportContainer,
|
||||
},
|
||||
data() {
|
||||
@@ -88,10 +88,10 @@ export default {
|
||||
<template>
|
||||
<ReportHeader :header-title="$t('BOT_REPORTS.HEADER')" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<ReportFilterSelector
|
||||
:show-agents-filter="false"
|
||||
show-group-by-filter
|
||||
:show-business-hours-switch="false"
|
||||
<ReportFilters
|
||||
:show-entity-filter="false"
|
||||
show-group-by
|
||||
:show-business-hours="false"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ export default {
|
||||
);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
getAllData() {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import ReportFilters from './components/ReportFilters.vue';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import { generateFileName } from 'dashboard/helper/downloadHelper';
|
||||
@@ -22,7 +22,7 @@ export default {
|
||||
name: 'ConversationReports',
|
||||
components: {
|
||||
ReportHeader,
|
||||
ReportFilterSelector,
|
||||
ReportFilters,
|
||||
ReportContainer,
|
||||
V4Button,
|
||||
},
|
||||
@@ -115,10 +115,10 @@ export default {
|
||||
@click="downloadConversationReports"
|
||||
/>
|
||||
</ReportHeader>
|
||||
<div class="flex flex-col gap-3">
|
||||
<ReportFilterSelector
|
||||
:show-agents-filter="false"
|
||||
show-group-by-filter
|
||||
<div class="flex flex-col">
|
||||
<ReportFilters
|
||||
:show-entity-filter="false"
|
||||
show-group-by
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
<ReportContainer :group-by="groupBy" />
|
||||
|
||||
@@ -145,7 +145,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 px-6 py-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 px-6 py-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 mt-4"
|
||||
>
|
||||
<div
|
||||
v-for="metric in metrics"
|
||||
|
||||
+56
-9
@@ -2,6 +2,8 @@
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { subDays, fromUnixTime } from 'date-fns';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import {
|
||||
buildFilterList,
|
||||
@@ -13,6 +15,12 @@ import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
||||
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
|
||||
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import {
|
||||
parseReportURLParams,
|
||||
parseFilterURLParams,
|
||||
generateCompleteURLParams,
|
||||
} from '../../helpers/reportFilterHelper';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
|
||||
const props = defineProps({
|
||||
showTeamFilter: {
|
||||
@@ -25,16 +33,29 @@ const emit = defineEmits(['filterChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize from URL params immediately
|
||||
const urlParams = parseReportURLParams(route.query);
|
||||
const urlFilters = parseFilterURLParams(route.query);
|
||||
|
||||
const initialDateRange =
|
||||
urlParams.from && urlParams.to
|
||||
? [fromUnixTime(urlParams.from), fromUnixTime(urlParams.to)]
|
||||
: [subDays(new Date(), 6), new Date()];
|
||||
|
||||
const showDropdownMenu = ref(false);
|
||||
const showSubDropdownMenu = ref(false);
|
||||
const activeFilterType = ref('');
|
||||
const customDateRange = ref([new Date(), new Date()]);
|
||||
const customDateRange = ref(initialDateRange);
|
||||
const selectedDateRange = ref(urlParams.range || DATE_RANGE_TYPES.LAST_7_DAYS);
|
||||
|
||||
const appliedFilters = ref({
|
||||
user_ids: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
rating: null,
|
||||
user_ids: urlFilters.agent_id,
|
||||
inbox_id: urlFilters.inbox_id,
|
||||
team_id: urlFilters.team_id,
|
||||
rating: urlFilters.rating,
|
||||
});
|
||||
|
||||
const agents = computed(() => store.getters['agents/getAgents']);
|
||||
@@ -111,13 +132,17 @@ const activeFilters = computed(() => {
|
||||
const activeKeys = Object.keys(appliedFilters.value).filter(
|
||||
key => appliedFilters.value[key]
|
||||
);
|
||||
|
||||
return activeKeys.map(key => {
|
||||
const filterType = getFilterType(key, 'keyToType');
|
||||
const items = getFilterSource(filterType);
|
||||
const item = getActiveFilter(items, filterType, appliedFilters.value[key]);
|
||||
const displayName =
|
||||
item?.name || item?.title || `ID: ${appliedFilters.value[key]}`;
|
||||
|
||||
return {
|
||||
id: item?.id,
|
||||
name: item?.name || '',
|
||||
id: item?.id || appliedFilters.value[key],
|
||||
name: displayName,
|
||||
type: filterType,
|
||||
options: getFilterOptions(filterType),
|
||||
};
|
||||
@@ -130,7 +155,23 @@ const hasActiveFilters = computed(() =>
|
||||
|
||||
const isAllFilterSelected = computed(() => !filterListMenuItems.value.length);
|
||||
|
||||
const updateURLParams = () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
range: selectedDateRange.value,
|
||||
filters: {
|
||||
agent_id: appliedFilters.value.user_ids,
|
||||
inbox_id: appliedFilters.value.inbox_id,
|
||||
team_id: appliedFilters.value.team_id,
|
||||
rating: appliedFilters.value.rating,
|
||||
},
|
||||
});
|
||||
router.replace({ query: params });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
updateURLParams();
|
||||
emit('filterChange', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
@@ -200,7 +241,9 @@ const openActiveFilterDropdown = filterType => {
|
||||
};
|
||||
|
||||
const onDateRangeChange = value => {
|
||||
customDateRange.value = value;
|
||||
const [startDate, endDate, rangeType] = value;
|
||||
customDateRange.value = [startDate, endDate];
|
||||
selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
@@ -211,7 +254,11 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
|
||||
<WootDatePicker @date-range-changed="onDateRangeChange" />
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col flex-wrap items-start gap-2 md:items-center md:flex-nowrap md:flex-row"
|
||||
|
||||
-228
@@ -1,228 +0,0 @@
|
||||
<script>
|
||||
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
|
||||
import ReportsFiltersDateRange from './Filters/DateRange.vue';
|
||||
import ReportsFiltersDateGroupBy from './Filters/DateGroupBy.vue';
|
||||
import ReportsFiltersAgents from './Filters/Agents.vue';
|
||||
import ReportsFiltersLabels from './Filters/Labels.vue';
|
||||
import ReportsFiltersInboxes from './Filters/Inboxes.vue';
|
||||
import ReportsFiltersTeams from './Filters/Teams.vue';
|
||||
import ReportsFiltersRatings from './Filters/Ratings.vue';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import { DATE_RANGE_OPTIONS } from '../constants';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootDateRangePicker,
|
||||
ReportsFiltersDateRange,
|
||||
ReportsFiltersDateGroupBy,
|
||||
ReportsFiltersAgents,
|
||||
ReportsFiltersLabels,
|
||||
ReportsFiltersInboxes,
|
||||
ReportsFiltersTeams,
|
||||
ReportsFiltersRatings,
|
||||
ToggleSwitch,
|
||||
},
|
||||
props: {
|
||||
showGroupByFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showAgentsFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showLabelsFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showInboxFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showRatingFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showTeamFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showBusinessHoursSwitch: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['filterChange'],
|
||||
data() {
|
||||
return {
|
||||
// default value, need not be translated
|
||||
selectedDateRange: DATE_RANGE_OPTIONS.LAST_7_DAYS,
|
||||
selectedGroupByFilter: null,
|
||||
selectedLabel: null,
|
||||
selectedInbox: null,
|
||||
selectedTeam: null,
|
||||
selectedRating: null,
|
||||
selectedAgents: [],
|
||||
customDateRange: [new Date(), new Date()],
|
||||
businessHoursSelected: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isDateRangeSelected() {
|
||||
return (
|
||||
this.selectedDateRange.id === DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE.id
|
||||
);
|
||||
},
|
||||
isGroupByPossible() {
|
||||
return this.selectedDateRange.id !== DATE_RANGE_OPTIONS.LAST_7_DAYS.id;
|
||||
},
|
||||
to() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return getUnixEndOfDay(this.customDateRange[1]);
|
||||
}
|
||||
return getUnixEndOfDay(new Date());
|
||||
},
|
||||
from() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return getUnixStartOfDay(this.customDateRange[0]);
|
||||
}
|
||||
|
||||
const { offset } = this.selectedDateRange;
|
||||
const fromDate = subDays(new Date(), offset);
|
||||
return getUnixStartOfDay(fromDate);
|
||||
},
|
||||
validGroupOptions() {
|
||||
return this.selectedDateRange.groupByOptions;
|
||||
},
|
||||
validGroupBy() {
|
||||
if (!this.selectedGroupByFilter) {
|
||||
return this.validGroupOptions[0];
|
||||
}
|
||||
|
||||
const validIds = this.validGroupOptions.map(opt => opt.id);
|
||||
if (validIds.includes(this.selectedGroupByFilter.id)) {
|
||||
return this.selectedGroupByFilter;
|
||||
}
|
||||
return this.validGroupOptions[0];
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.emitChange();
|
||||
},
|
||||
methods: {
|
||||
emitChange() {
|
||||
const {
|
||||
from,
|
||||
to,
|
||||
selectedGroupByFilter: groupBy,
|
||||
businessHoursSelected: businessHours,
|
||||
selectedAgents,
|
||||
selectedLabel,
|
||||
selectedInbox,
|
||||
selectedTeam,
|
||||
selectedRating,
|
||||
} = this;
|
||||
this.$emit('filterChange', {
|
||||
from,
|
||||
to,
|
||||
groupBy,
|
||||
businessHours,
|
||||
selectedAgents,
|
||||
selectedLabel,
|
||||
selectedInbox,
|
||||
selectedTeam,
|
||||
selectedRating,
|
||||
});
|
||||
},
|
||||
onDateRangeChange(selectedRange) {
|
||||
this.selectedDateRange = selectedRange;
|
||||
this.selectedGroupByFilter = this.validGroupBy;
|
||||
this.emitChange();
|
||||
},
|
||||
onCustomDateRangeChange(value) {
|
||||
this.customDateRange = value;
|
||||
this.selectedGroupByFilter = this.validGroupBy;
|
||||
this.emitChange();
|
||||
},
|
||||
onGroupingChange(payload) {
|
||||
this.selectedGroupByFilter = payload;
|
||||
this.emitChange();
|
||||
},
|
||||
handleAgentsFilterSelection(selectedAgents) {
|
||||
this.selectedAgents = selectedAgents;
|
||||
this.emitChange();
|
||||
},
|
||||
handleLabelsFilterSelection(selectedLabel) {
|
||||
this.selectedLabel = selectedLabel;
|
||||
this.emitChange();
|
||||
},
|
||||
handleInboxFilterSelection(selectedInbox) {
|
||||
this.selectedInbox = selectedInbox;
|
||||
this.emitChange();
|
||||
},
|
||||
handleTeamFilterSelection(selectedTeam) {
|
||||
this.selectedTeam = selectedTeam;
|
||||
this.emitChange();
|
||||
},
|
||||
handleRatingFilterSelection(selectedRating) {
|
||||
this.selectedRating = selectedRating;
|
||||
this.emitChange();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between gap-3 md:flex-row">
|
||||
<div
|
||||
class="w-full grid gap-y-2 gap-x-1.5 grid-cols-[repeat(auto-fill,minmax(250px,1fr))]"
|
||||
>
|
||||
<ReportsFiltersDateRange @on-range-change="onDateRangeChange" />
|
||||
<WootDateRangePicker
|
||||
v-if="isDateRangeSelected"
|
||||
show-range
|
||||
class="no-margin auto-width"
|
||||
:value="customDateRange"
|
||||
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
|
||||
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
|
||||
@change="onCustomDateRangeChange"
|
||||
/>
|
||||
<ReportsFiltersDateGroupBy
|
||||
v-if="showGroupByFilter && isGroupByPossible"
|
||||
:valid-group-options="validGroupOptions"
|
||||
:selected-option="selectedGroupByFilter"
|
||||
@on-grouping-change="onGroupingChange"
|
||||
/>
|
||||
<ReportsFiltersAgents
|
||||
v-if="showAgentsFilter"
|
||||
@agents-filter-selection="handleAgentsFilterSelection"
|
||||
/>
|
||||
<ReportsFiltersLabels
|
||||
v-if="showLabelsFilter"
|
||||
@labels-filter-selection="handleLabelsFilterSelection"
|
||||
/>
|
||||
<ReportsFiltersTeams
|
||||
v-if="showTeamFilter"
|
||||
@team-filter-selection="handleTeamFilterSelection"
|
||||
/>
|
||||
<ReportsFiltersInboxes
|
||||
v-if="showInboxFilter"
|
||||
@inbox-filter-selection="handleInboxFilterSelection"
|
||||
/>
|
||||
<ReportsFiltersRatings
|
||||
v-if="showRatingFilter"
|
||||
@rating-filter-selection="handleRatingFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showBusinessHoursSwitch" class="flex items-center">
|
||||
<span class="mx-2 text-sm whitespace-nowrap">
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<ToggleSwitch v-model="businessHoursSelected" @change="emitChange" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersAgents',
|
||||
emits: ['agentsFilterSelection'],
|
||||
data() {
|
||||
return {
|
||||
selectedOptions: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'agents/getAgents',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('agentsFilterSelection', this.selectedOptions);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOptions"
|
||||
class="no-margin"
|
||||
:options="options"
|
||||
track-by="id"
|
||||
label="name"
|
||||
multiple
|
||||
:close-on-select="false"
|
||||
:clear-on-select="false"
|
||||
hide-selected
|
||||
:placeholder="$t('CSAT_REPORTS.FILTERS.AGENTS.PLACEHOLDER')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
@update:model-value="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
<script>
|
||||
import { GROUP_BY_OPTIONS } from '../../constants';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersDateGroupBy',
|
||||
props: {
|
||||
validGroupOptions: {
|
||||
type: Array,
|
||||
default: () => [GROUP_BY_OPTIONS.DAY],
|
||||
},
|
||||
selectedOption: {
|
||||
type: Object,
|
||||
default: () => GROUP_BY_OPTIONS.DAY,
|
||||
},
|
||||
},
|
||||
emits: ['onGroupingChange'],
|
||||
data() {
|
||||
return {
|
||||
currentSelectedFilter: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
translatedOptions() {
|
||||
return this.validGroupOptions.map(option => ({
|
||||
...option,
|
||||
groupBy: this.$t(option.translationKey),
|
||||
}));
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedOption: {
|
||||
handler() {
|
||||
this.currentSelectedFilter = {
|
||||
...this.selectedOption,
|
||||
groupBy: this.$t(this.selectedOption.translationKey),
|
||||
};
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
changeFilterSelection(selectedFilter) {
|
||||
this.groupByOptions = this.$emit('onGroupingChange', selectedFilter);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<p aria-hidden="true" class="hidden">
|
||||
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
class="no-margin"
|
||||
track-by="id"
|
||||
label="groupBy"
|
||||
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
|
||||
:options="translatedOptions"
|
||||
:allow-empty="false"
|
||||
:show-labels="false"
|
||||
@select="changeFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DATE_RANGE_OPTIONS } from '../../constants';
|
||||
|
||||
const emit = defineEmits(['onRangeChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const options = computed(() =>
|
||||
Object.values(DATE_RANGE_OPTIONS).map(option => ({
|
||||
...option,
|
||||
name: t(option.translationKey),
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedId = ref(Object.values(DATE_RANGE_OPTIONS)[0].id);
|
||||
|
||||
const selectedOption = computed({
|
||||
get() {
|
||||
return options.value.find(o => o.id === selectedId.value);
|
||||
},
|
||||
set(val) {
|
||||
selectedId.value = val.id;
|
||||
},
|
||||
});
|
||||
|
||||
const updateRange = range => {
|
||||
selectedOption.value = range;
|
||||
emit('onRangeChange', range);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:options="options"
|
||||
:searchable="false"
|
||||
:allow-empty="false"
|
||||
@select="updateRange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersInboxes',
|
||||
emits: ['inboxFilterSelection'],
|
||||
data() {
|
||||
return {
|
||||
selectedOption: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'inboxes/getInboxes',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxes/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('inboxFilterSelection', this.selectedOption);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:placeholder="$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
label="name"
|
||||
track-by="id"
|
||||
:options="options"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersLabels',
|
||||
emits: ['labelsFilterSelection'],
|
||||
data() {
|
||||
return {
|
||||
selectedOption: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'labels/getLabels',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('labels/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('labelsFilterSelection', this.selectedOption);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:placeholder="$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
label="title"
|
||||
track-by="id"
|
||||
:options="options"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="handleInput"
|
||||
>
|
||||
<template #singleLabel="props">
|
||||
<div class="flex items-center min-w-0 gap-2">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="w-5 h-5 rounded-full"
|
||||
/>
|
||||
<span class="my-0 text-n-slate-12">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="props">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="flex-shrink-0 w-5 h-5 border border-solid rounded-full border-n-weak"
|
||||
/>
|
||||
|
||||
<span class="my-0 text-n-slate-12 truncate min-w-0">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
</template>
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
<script>
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'ReportFiltersRatings',
|
||||
emits: ['ratingFilterSelection'],
|
||||
data() {
|
||||
const translatedOptions = CSAT_RATINGS.reverse().map(option => ({
|
||||
...option,
|
||||
label: this.$t(option.translationKey),
|
||||
}));
|
||||
|
||||
return {
|
||||
selectedOption: null,
|
||||
options: translatedOptions,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleInput(selectedRating) {
|
||||
this.$emit('ratingFilterSelection', selectedRating);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:option-height="24"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
|
||||
:options="options"
|
||||
:show-labels="false"
|
||||
track-by="value"
|
||||
label="label"
|
||||
@update:model-value="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersTeams',
|
||||
emits: ['teamFilterSelection'],
|
||||
data() {
|
||||
return {
|
||||
selectedOption: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'teams/getTeams',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('teams/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('teamFilterSelection', this.selectedOption);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:placeholder="$t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
label="name"
|
||||
track-by="id"
|
||||
:options="options"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+7
-3
@@ -8,8 +8,8 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
type: [Number, null],
|
||||
default: null,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
@@ -35,6 +35,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showClearFilter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -60,7 +64,7 @@ const closeDropdown = () => emit('closeDropdown');
|
||||
<FilterListDropdown
|
||||
v-if="options"
|
||||
v-on-clickaway="closeDropdown"
|
||||
show-clear-filter
|
||||
:show-clear-filter="showClearFilter"
|
||||
:list-items="options"
|
||||
:active-filter-id="id"
|
||||
:input-placeholder="placeholder"
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
} from '../helpers/reportFilterHelper';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['filterChange']);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const customDateRange = ref([subDays(new Date(), 6), new Date()]);
|
||||
const selectedDateRange = ref(DATE_RANGE_TYPES.LAST_7_DAYS);
|
||||
const businessHoursSelected = ref(false);
|
||||
|
||||
const updateURLParams = () => {
|
||||
const params = generateReportURLParams({
|
||||
from: getUnixStartOfDay(customDateRange.value[0]),
|
||||
to: getUnixEndOfDay(customDateRange.value[1]),
|
||||
businessHours: businessHoursSelected.value,
|
||||
range: selectedDateRange.value,
|
||||
});
|
||||
|
||||
router.replace({ query: { ...params } });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
updateURLParams();
|
||||
emit('filterChange', {
|
||||
from: getUnixStartOfDay(customDateRange.value[0]),
|
||||
to: getUnixEndOfDay(customDateRange.value[1]),
|
||||
businessHours: businessHoursSelected.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onDateRangeChange = value => {
|
||||
const [startDate, endDate, rangeType] = value;
|
||||
customDateRange.value = [startDate, endDate];
|
||||
selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const onBusinessHoursToggle = () => {
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
const urlParams = parseReportURLParams(route.query);
|
||||
|
||||
// Set the range type first
|
||||
if (urlParams.range) {
|
||||
selectedDateRange.value = urlParams.range;
|
||||
}
|
||||
|
||||
// Restore dates from URL if available
|
||||
if (urlParams.from && urlParams.to) {
|
||||
customDateRange.value = [
|
||||
new Date(urlParams.from * 1000),
|
||||
new Date(urlParams.to * 1000),
|
||||
];
|
||||
}
|
||||
|
||||
if (urlParams.businessHours) {
|
||||
businessHoursSelected.value = urlParams.businessHours;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
emitChange();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-between gap-3 md:flex-row"
|
||||
:class="{ 'pointer-events-none opacity-50': disabled }"
|
||||
>
|
||||
<div class="flex flex-col flex-wrap items-start gap-2 md:flex-row">
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="mx-2 text-sm whitespace-nowrap">
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<ToggleSwitch
|
||||
v-model="businessHoursSelected"
|
||||
@change="onBusinessHoursToggle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+361
-328
@@ -1,349 +1,382 @@
|
||||
<script>
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
|
||||
import differenceInDays from 'date-fns/differenceInDays';
|
||||
import ActiveFilterChip from './Filters/v3/ActiveFilterChip.vue';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
import { GROUP_BY_FILTER } from '../constants';
|
||||
const CUSTOM_DATE_RANGE_ID = 5;
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
} from '../helpers/reportFilterHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootDateRangePicker,
|
||||
Avatar,
|
||||
ToggleSwitch,
|
||||
const props = defineProps({
|
||||
filterType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
validator: value =>
|
||||
['teams', 'inboxes', 'labels', 'agents', ''].includes(value),
|
||||
},
|
||||
props: {
|
||||
currentFilter: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
filterItemsList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
groupByFilterItemsList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'agent',
|
||||
},
|
||||
selectedGroupByFilter: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
selectedItem: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
emits: [
|
||||
'businessHoursToggle',
|
||||
'dateRangeChange',
|
||||
'filterChange',
|
||||
'groupByFilterChange',
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
currentSelectedFilter: this.currentFilter || null,
|
||||
currentDateRangeSelection: {
|
||||
id: 0,
|
||||
name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||
},
|
||||
customDateRange: [new Date(), new Date()],
|
||||
currentSelectedGroupByFilter: null,
|
||||
businessHoursSelected: false,
|
||||
};
|
||||
showGroupBy: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
computed: {
|
||||
dateRange() {
|
||||
return [
|
||||
{ id: 0, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS') },
|
||||
{ id: 1, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS') },
|
||||
{ id: 2, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_3_MONTHS') },
|
||||
{ id: 3, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_6_MONTHS') },
|
||||
{ id: 4, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_YEAR') },
|
||||
{ id: 5, name: this.$t('REPORT.DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE') },
|
||||
];
|
||||
},
|
||||
isDateRangeSelected() {
|
||||
return this.currentDateRangeSelection.id === CUSTOM_DATE_RANGE_ID;
|
||||
},
|
||||
to() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return this.toCustomDate(this.customDateRange[1]);
|
||||
}
|
||||
return this.toCustomDate(new Date());
|
||||
},
|
||||
from() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return this.fromCustomDate(this.customDateRange[0]);
|
||||
}
|
||||
const dateRange = {
|
||||
0: 6,
|
||||
1: 29,
|
||||
2: 89,
|
||||
3: 179,
|
||||
4: 364,
|
||||
};
|
||||
const diff = dateRange[this.currentDateRangeSelection.id];
|
||||
const fromDate = subDays(new Date(), diff);
|
||||
return this.fromCustomDate(fromDate);
|
||||
},
|
||||
multiselectLabel() {
|
||||
const typeLabels = {
|
||||
agent: this.$t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL'),
|
||||
label: this.$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL'),
|
||||
inbox: this.$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL'),
|
||||
team: this.$t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL'),
|
||||
};
|
||||
return typeLabels[this.type] || this.$t('FORMS.MULTISELECT.SELECT_ONE');
|
||||
},
|
||||
groupBy() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return GROUP_BY_FILTER[4].period;
|
||||
}
|
||||
const groupRange = {
|
||||
0: GROUP_BY_FILTER[1].period,
|
||||
1: GROUP_BY_FILTER[2].period,
|
||||
2: GROUP_BY_FILTER[3].period,
|
||||
3: GROUP_BY_FILTER[3].period,
|
||||
4: GROUP_BY_FILTER[4].period,
|
||||
};
|
||||
return groupRange[this.currentDateRangeSelection.id];
|
||||
},
|
||||
notLast7Days() {
|
||||
return this.groupBy !== GROUP_BY_FILTER[1].period;
|
||||
},
|
||||
showBusinessHours: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
watch: {
|
||||
filterItemsList(val) {
|
||||
this.currentSelectedFilter = !this.currentFilter
|
||||
? val[0]
|
||||
: this.currentFilter;
|
||||
this.changeFilterSelection();
|
||||
},
|
||||
groupByFilterItemsList() {
|
||||
this.currentSelectedGroupByFilter = this.selectedGroupByFilter;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.onDateRangeChange();
|
||||
},
|
||||
methods: {
|
||||
onDateRangeChange() {
|
||||
this.$emit('dateRangeChange', {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
groupBy: this.groupBy,
|
||||
});
|
||||
},
|
||||
onBusinessHoursToggle() {
|
||||
this.$emit('businessHoursToggle', this.businessHoursSelected);
|
||||
},
|
||||
fromCustomDate(date) {
|
||||
return getUnixTime(startOfDay(date));
|
||||
},
|
||||
toCustomDate(date) {
|
||||
return getUnixTime(endOfDay(date));
|
||||
},
|
||||
changeDateSelection(selectedRange) {
|
||||
this.currentDateRangeSelection = selectedRange;
|
||||
this.onDateRangeChange();
|
||||
},
|
||||
changeFilterSelection() {
|
||||
this.$emit('filterChange', this.currentSelectedFilter);
|
||||
},
|
||||
onChange(value) {
|
||||
this.customDateRange = value;
|
||||
this.onDateRangeChange();
|
||||
},
|
||||
changeGroupByFilterSelection() {
|
||||
this.$emit('groupByFilterChange', this.currentSelectedGroupByFilter);
|
||||
},
|
||||
showEntityFilter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['filterChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const buildReportFilterList = (items, type) => {
|
||||
if (!Array.isArray(items)) return [];
|
||||
|
||||
return items.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name || item.title,
|
||||
type,
|
||||
}));
|
||||
};
|
||||
|
||||
const getReportFilterKey = filterType => {
|
||||
const keyMap = {
|
||||
teams: 'team_id',
|
||||
inboxes: 'inbox_id',
|
||||
labels: 'label_id',
|
||||
agents: 'agent_id',
|
||||
};
|
||||
return keyMap[filterType] || '';
|
||||
};
|
||||
|
||||
const getFilterKey = () => getReportFilterKey(props.filterType);
|
||||
|
||||
const showSubDropdownMenu = ref(false);
|
||||
const showGroupByDropdown = ref(false);
|
||||
const activeFilterType = ref('');
|
||||
const customDateRange = ref([subDays(new Date(), 6), new Date()]);
|
||||
const selectedDateRange = ref(DATE_RANGE_TYPES.LAST_7_DAYS);
|
||||
const businessHoursSelected = ref(false);
|
||||
const groupBy = ref(GROUP_BY_FILTER[1]);
|
||||
const groupByfilterItemsList = ref([{ id: 1, name: 'Day' }]);
|
||||
|
||||
const appliedFilters = ref(
|
||||
props.showEntityFilter
|
||||
? { [getFilterKey()]: props.selectedItem?.id || null }
|
||||
: {}
|
||||
);
|
||||
|
||||
const filterSource = computed(() => {
|
||||
const sources = {
|
||||
teams: store.getters['teams/getTeams'],
|
||||
inboxes: store.getters['inboxes/getInboxes'],
|
||||
labels: store.getters['labels/getLabels'],
|
||||
agents: store.getters['agents/getAgents'],
|
||||
};
|
||||
return sources[props.filterType] || [];
|
||||
});
|
||||
|
||||
const from = computed(() => getUnixStartOfDay(customDateRange.value[0]));
|
||||
const to = computed(() => getUnixEndOfDay(customDateRange.value[1]));
|
||||
|
||||
const daysDifference = computed(() => {
|
||||
return differenceInDays(customDateRange.value[1], customDateRange.value[0]);
|
||||
});
|
||||
|
||||
const isGroupByPossible = computed(() => {
|
||||
return props.showGroupBy && daysDifference.value >= 29;
|
||||
});
|
||||
|
||||
const GROUP_BY_OPTIONS = computed(() => ({
|
||||
WEEK: [
|
||||
{ id: 1, name: t('REPORT.GROUPING_OPTIONS.DAY') },
|
||||
{ id: 2, name: t('REPORT.GROUPING_OPTIONS.WEEK') },
|
||||
],
|
||||
MONTH: [
|
||||
{ id: 1, name: t('REPORT.GROUPING_OPTIONS.DAY') },
|
||||
{ id: 2, name: t('REPORT.GROUPING_OPTIONS.WEEK') },
|
||||
{ id: 3, name: t('REPORT.GROUPING_OPTIONS.MONTH') },
|
||||
],
|
||||
YEAR: [
|
||||
{ id: 2, name: t('REPORT.GROUPING_OPTIONS.WEEK') },
|
||||
{ id: 3, name: t('REPORT.GROUPING_OPTIONS.MONTH') },
|
||||
{ id: 4, name: t('REPORT.GROUPING_OPTIONS.YEAR') },
|
||||
],
|
||||
}));
|
||||
|
||||
const fetchFilterItems = () => {
|
||||
const days = daysDifference.value;
|
||||
if (days >= 364) return GROUP_BY_OPTIONS.value.YEAR;
|
||||
if (days >= 90) return GROUP_BY_OPTIONS.value.MONTH;
|
||||
if (days >= 29) return GROUP_BY_OPTIONS.value.WEEK;
|
||||
return GROUP_BY_OPTIONS.value.WEEK;
|
||||
};
|
||||
|
||||
const filterOptions = computed(() =>
|
||||
buildReportFilterList(filterSource.value, props.filterType)
|
||||
);
|
||||
|
||||
const filterPlaceholder = computed(() => {
|
||||
const placeholders = {
|
||||
teams: 'TEAM_REPORTS.FILTERS.INPUT_PLACEHOLDER.TEAMS',
|
||||
inboxes: 'INBOX_REPORTS.FILTERS.INPUT_PLACEHOLDER.INBOXES',
|
||||
labels: 'LABEL_REPORTS.FILTERS.INPUT_PLACEHOLDER.LABELS',
|
||||
agents: 'AGENT_REPORTS.FILTERS.INPUT_PLACEHOLDER.AGENTS',
|
||||
};
|
||||
return t(placeholders[props.filterType] || '');
|
||||
});
|
||||
|
||||
const defaultFilterLabel = computed(() => {
|
||||
const labelKeys = {
|
||||
teams: 'TEAM_REPORTS.FILTER_DROPDOWN_LABEL',
|
||||
inboxes: 'INBOX_REPORTS.FILTER_DROPDOWN_LABEL',
|
||||
labels: 'LABEL_REPORTS.FILTER_DROPDOWN_LABEL',
|
||||
agents: 'AGENT_REPORTS.FILTER_DROPDOWN_LABEL',
|
||||
};
|
||||
return t(labelKeys[props.filterType] || 'FORMS.MULTISELECT.SELECT_ONE');
|
||||
});
|
||||
|
||||
const selectedFilterName = computed(() => {
|
||||
const filterKey = getFilterKey();
|
||||
const selectedId = appliedFilters.value[filterKey];
|
||||
|
||||
if (!selectedId) {
|
||||
return defaultFilterLabel.value;
|
||||
}
|
||||
|
||||
const selectedItem = filterOptions.value.find(item => item.id === selectedId);
|
||||
return selectedItem?.name || defaultFilterLabel.value;
|
||||
});
|
||||
|
||||
const updateURLParams = () => {
|
||||
const params = generateReportURLParams({
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
businessHours: businessHoursSelected.value,
|
||||
groupBy: isGroupByPossible.value ? groupBy.value.id : null,
|
||||
range: selectedDateRange.value,
|
||||
});
|
||||
|
||||
router.replace({ query: { ...params } });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
const payload = {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
businessHours: businessHoursSelected.value,
|
||||
};
|
||||
|
||||
if (props.showGroupBy) {
|
||||
// Always emit groupBy, default to day when range is too short
|
||||
payload.groupBy = isGroupByPossible.value
|
||||
? groupBy.value
|
||||
: GROUP_BY_FILTER[1];
|
||||
}
|
||||
|
||||
if (props.showEntityFilter) {
|
||||
const filterKey = getFilterKey();
|
||||
const selectedValue = appliedFilters.value[filterKey];
|
||||
|
||||
if (selectedValue) {
|
||||
payload[props.filterType] =
|
||||
props.filterType === 'agents'
|
||||
? [{ id: selectedValue }]
|
||||
: { id: selectedValue };
|
||||
}
|
||||
}
|
||||
|
||||
updateURLParams();
|
||||
emit('filterChange', payload);
|
||||
};
|
||||
|
||||
const closeActiveFilterDropdown = () => {
|
||||
showSubDropdownMenu.value = false;
|
||||
activeFilterType.value = '';
|
||||
};
|
||||
|
||||
const openActiveFilterDropdown = filterType => {
|
||||
showGroupByDropdown.value = false;
|
||||
activeFilterType.value = filterType;
|
||||
showSubDropdownMenu.value = !showSubDropdownMenu.value;
|
||||
};
|
||||
|
||||
const addFilter = item => {
|
||||
const filterKey = getFilterKey();
|
||||
appliedFilters.value[filterKey] = item.id;
|
||||
closeActiveFilterDropdown();
|
||||
emitChange();
|
||||
|
||||
// Navigate to the new entity's route
|
||||
const routeNameMap = {
|
||||
teams: 'team_reports_show',
|
||||
inboxes: 'inbox_reports_show',
|
||||
labels: 'label_reports_show',
|
||||
agents: 'agent_reports_show',
|
||||
};
|
||||
|
||||
const routeName = routeNameMap[props.filterType];
|
||||
if (routeName) {
|
||||
router.push({
|
||||
name: routeName,
|
||||
params: { ...route.params, id: item.id },
|
||||
query: route.query,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDateRangeChange = value => {
|
||||
const [startDate, endDate, rangeType] = value;
|
||||
customDateRange.value = [startDate, endDate];
|
||||
selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE;
|
||||
groupByfilterItemsList.value = fetchFilterItems();
|
||||
const filterItems = groupByfilterItemsList.value.filter(
|
||||
item => item.id === groupBy.value.id
|
||||
);
|
||||
if (filterItems.length === 0) {
|
||||
groupBy.value = GROUP_BY_FILTER[groupByfilterItemsList.value[0].id];
|
||||
}
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const onBusinessHoursToggle = () => {
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const onGroupByFilterChange = payload => {
|
||||
groupBy.value = GROUP_BY_FILTER[payload.id];
|
||||
showGroupByDropdown.value = false;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const toggleGroupByDropdown = () => {
|
||||
showGroupByDropdown.value = !showGroupByDropdown.value;
|
||||
};
|
||||
|
||||
const closeGroupByDropdown = () => {
|
||||
showGroupByDropdown.value = false;
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
const urlParams = parseReportURLParams(route.query);
|
||||
|
||||
// Set the range type first
|
||||
if (urlParams.range) {
|
||||
selectedDateRange.value = urlParams.range;
|
||||
}
|
||||
|
||||
// Restore dates from URL if available
|
||||
if (urlParams.from && urlParams.to) {
|
||||
customDateRange.value = [
|
||||
new Date(urlParams.from * 1000),
|
||||
new Date(urlParams.to * 1000),
|
||||
];
|
||||
}
|
||||
|
||||
if (urlParams.businessHours) {
|
||||
businessHoursSelected.value = urlParams.businessHours;
|
||||
}
|
||||
|
||||
if (urlParams.groupBy) {
|
||||
const groupByValue = GROUP_BY_FILTER[urlParams.groupBy];
|
||||
if (groupByValue) {
|
||||
groupBy.value = groupByValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize entity filter from route params (not URL query)
|
||||
if (props.showEntityFilter && route.params.id) {
|
||||
const filterKey = getFilterKey();
|
||||
appliedFilters.value[filterKey] = Number(route.params.id);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
groupByfilterItemsList.value = fetchFilterItems();
|
||||
emitChange();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-y-0.5 gap-x-2">
|
||||
<div v-if="type === 'agent'" class="multiselect-wrap--small">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
:placeholder="multiselectLabel"
|
||||
label="name"
|
||||
track-by="id"
|
||||
:options="filterItemsList"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="changeFilterSelection"
|
||||
>
|
||||
<template #singleLabel="props">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Avatar
|
||||
:src="props.option.thumbnail"
|
||||
:status="props.option.availability_status"
|
||||
:name="props.option.name"
|
||||
:size="22"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<span class="my-0 text-n-slate-12 truncate">{{
|
||||
props.option.name
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #options="props">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar
|
||||
:src="props.option.thumbnail"
|
||||
:status="props.option.availability_status"
|
||||
:name="props.option.name"
|
||||
:size="22"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<p class="my-0 text-n-slate-12">
|
||||
{{ props.option.name }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-3 lg:flex-row">
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
/>
|
||||
|
||||
<div v-else-if="type === 'label'" class="multiselect-wrap--small">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
:placeholder="multiselectLabel"
|
||||
label="title"
|
||||
track-by="id"
|
||||
:options="filterItemsList"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="changeFilterSelection"
|
||||
>
|
||||
<template #singleLabel="props">
|
||||
<div class="flex items-center min-w-0 gap-2">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="w-5 h-5 rounded-full"
|
||||
/>
|
||||
|
||||
<span class="my-0 text-n-slate-12 truncate">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="props">
|
||||
<div class="flex items-center min-w-0 gap-2">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="flex-shrink-0 w-5 h-5 border border-solid rounded-full border-n-weak"
|
||||
/>
|
||||
|
||||
<span class="my-0 text-n-slate-12 truncate">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
|
||||
<div v-else class="multiselect-wrap--small">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
<template v-if="type === 'inbox'">
|
||||
{{ $t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</template>
|
||||
<template v-else-if="type === 'team'">
|
||||
{{ $t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('FORMS.MULTISELECT.SELECT_ONE') }}
|
||||
</template>
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="multiselectLabel"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:options="filterItemsList"
|
||||
:searchable="false"
|
||||
:allow-empty="false"
|
||||
@update:model-value="changeFilterSelection"
|
||||
<div class="flex gap-2 items-center w-full">
|
||||
<ActiveFilterChip
|
||||
v-if="showEntityFilter"
|
||||
:id="appliedFilters[getFilterKey()]"
|
||||
:name="selectedFilterName"
|
||||
:type="filterType"
|
||||
:options="filterOptions"
|
||||
:active-filter-type="activeFilterType"
|
||||
:show-menu="showSubDropdownMenu"
|
||||
:placeholder="filterPlaceholder"
|
||||
:show-clear-filter="false"
|
||||
enable-search
|
||||
@toggle-dropdown="openActiveFilterDropdown"
|
||||
@close-dropdown="closeActiveFilterDropdown"
|
||||
@add-filter="addFilter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="multiselect-wrap--small">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('REPORT.DURATION_FILTER_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentDateRangeSelection"
|
||||
track-by="name"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:options="dateRange"
|
||||
:searchable="false"
|
||||
:allow-empty="false"
|
||||
@select="changeDateSelection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center h-10 self-center order-5 md:order-2 md:justify-self-end"
|
||||
>
|
||||
<span class="mr-2 text-sm whitespace-nowrap">
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<ToggleSwitch
|
||||
v-model="businessHoursSelected"
|
||||
@change="onBusinessHoursToggle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isDateRangeSelected" class="order-3 md:order-4">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER') }}
|
||||
</p>
|
||||
<WootDateRangePicker
|
||||
show-range
|
||||
:value="customDateRange"
|
||||
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
|
||||
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
|
||||
class="auto-width"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="notLast7Days" class="multiselect-wrap--small order-4 md:order-5">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedGroupByFilter"
|
||||
track-by="id"
|
||||
label="groupBy"
|
||||
<ActiveFilterChip
|
||||
v-if="isGroupByPossible"
|
||||
:id="groupBy?.id"
|
||||
:name="
|
||||
groupByfilterItemsList.find(item => item.id === groupBy?.id)?.name ||
|
||||
$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')
|
||||
"
|
||||
type="groupBy"
|
||||
:options="groupByfilterItemsList"
|
||||
:active-filter-type="showGroupByDropdown ? 'groupBy' : ''"
|
||||
:show-menu="showGroupByDropdown"
|
||||
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
|
||||
:options="groupByFilterItemsList"
|
||||
:allow-empty="false"
|
||||
:show-labels="false"
|
||||
@update:model-value="changeGroupByFilterSelection"
|
||||
:enable-search="false"
|
||||
:show-clear-filter="false"
|
||||
@toggle-dropdown="toggleGroupByDropdown"
|
||||
@close-dropdown="closeGroupByDropdown"
|
||||
@add-filter="onGroupByFilterChange"
|
||||
@remove-filter="() => {}"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="showBusinessHours"
|
||||
class="flex items-center flex-shrink-0 ltr:ml-auto rtl:mr-auto"
|
||||
>
|
||||
<span class="mx-2 text-sm whitespace-nowrap">
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<ToggleSwitch
|
||||
v-model="businessHoursSelected"
|
||||
@change="onBusinessHoursToggle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+185
-139
@@ -1,155 +1,201 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
buildFilterList,
|
||||
getActiveFilter,
|
||||
getFilterType,
|
||||
} from './helpers/SLAFilterHelpers';
|
||||
import {
|
||||
parseFilterURLParams,
|
||||
generateCompleteURLParams,
|
||||
} from '../../helpers/reportFilterHelper';
|
||||
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
||||
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
|
||||
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterButton,
|
||||
ActiveFilterChip,
|
||||
AddFilterChip,
|
||||
},
|
||||
emits: ['filterChange'],
|
||||
data() {
|
||||
const emit = defineEmits(['filterChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const showDropdownMenu = ref(false);
|
||||
const showSubDropdownMenu = ref(false);
|
||||
const activeFilterType = ref('');
|
||||
const appliedFilters = ref({
|
||||
assigned_agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label_list: null,
|
||||
});
|
||||
|
||||
const agents = computed(() => store.getters['agents/getAgents']);
|
||||
const inboxes = computed(() => store.getters['inboxes/getInboxes']);
|
||||
const teams = computed(() => store.getters['teams/getTeams']);
|
||||
const labels = computed(() => store.getters['labels/getLabels']);
|
||||
const sla = computed(() => store.getters['sla/getSLA']);
|
||||
|
||||
const filterListMenuItems = computed(() => {
|
||||
const filterTypes = [
|
||||
{ id: '1', name: t('SLA_REPORTS.DROPDOWN.SLA'), type: 'sla' },
|
||||
{ id: '2', name: t('SLA_REPORTS.DROPDOWN.INBOXES'), type: 'inboxes' },
|
||||
{ id: '3', name: t('SLA_REPORTS.DROPDOWN.AGENTS'), type: 'agents' },
|
||||
{ id: '4', name: t('SLA_REPORTS.DROPDOWN.TEAMS'), type: 'teams' },
|
||||
{ id: '5', name: t('SLA_REPORTS.DROPDOWN.LABELS'), type: 'labels' },
|
||||
];
|
||||
|
||||
const activeFilterKeys = Object.keys(appliedFilters.value).filter(
|
||||
key => appliedFilters.value[key]
|
||||
);
|
||||
const activeFilterTypes = activeFilterKeys.map(key =>
|
||||
getFilterType(key, 'keyToType')
|
||||
);
|
||||
|
||||
const sources = {
|
||||
agents: agents.value,
|
||||
inboxes: inboxes.value,
|
||||
teams: teams.value,
|
||||
labels: labels.value,
|
||||
sla: sla.value,
|
||||
};
|
||||
|
||||
return filterTypes
|
||||
.filter(({ type }) => !activeFilterTypes.includes(type))
|
||||
.map(({ id, name, type }) => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
options: buildFilterList(sources[type], type),
|
||||
}));
|
||||
});
|
||||
|
||||
const activeFilters = computed(() => {
|
||||
const activeKeys = Object.keys(appliedFilters.value).filter(
|
||||
key => appliedFilters.value[key]
|
||||
);
|
||||
|
||||
const sources = {
|
||||
agents: agents.value,
|
||||
inboxes: inboxes.value,
|
||||
teams: teams.value,
|
||||
labels: labels.value,
|
||||
sla: sla.value,
|
||||
};
|
||||
|
||||
return activeKeys.map(key => {
|
||||
const filterType = getFilterType(key, 'keyToType');
|
||||
const item = getActiveFilter(
|
||||
sources[filterType],
|
||||
filterType,
|
||||
appliedFilters.value[key]
|
||||
);
|
||||
return {
|
||||
showDropdownMenu: false,
|
||||
showSubDropdownMenu: false,
|
||||
activeFilterType: '',
|
||||
appliedFilters: {
|
||||
assigned_agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label_list: null,
|
||||
},
|
||||
id: item.id,
|
||||
name: filterType === 'labels' ? item.title : item.name,
|
||||
type: filterType,
|
||||
options: buildFilterList(sources[filterType], filterType),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
agents: 'agents/getAgents',
|
||||
inboxes: 'inboxes/getInboxes',
|
||||
teams: 'teams/getTeams',
|
||||
labels: 'labels/getLabels',
|
||||
sla: 'sla/getSLA',
|
||||
}),
|
||||
filterListMenuItems() {
|
||||
const filterTypes = [
|
||||
{ id: '1', name: this.$t('SLA_REPORTS.DROPDOWN.SLA'), type: 'sla' },
|
||||
{
|
||||
id: '2',
|
||||
name: this.$t('SLA_REPORTS.DROPDOWN.INBOXES'),
|
||||
type: 'inboxes',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: this.$t('SLA_REPORTS.DROPDOWN.AGENTS'),
|
||||
type: 'agents',
|
||||
},
|
||||
{ id: '4', name: this.$t('SLA_REPORTS.DROPDOWN.TEAMS'), type: 'teams' },
|
||||
{
|
||||
id: '5',
|
||||
name: this.$t('SLA_REPORTS.DROPDOWN.LABELS'),
|
||||
type: 'labels',
|
||||
},
|
||||
];
|
||||
// Filter out the active filters from the filter list
|
||||
// We only want to show the filters that are not already applied
|
||||
// In the add filter dropdown
|
||||
const activeFilters = Object.keys(this.appliedFilters).filter(
|
||||
key => this.appliedFilters[key]
|
||||
);
|
||||
const activeFilterTypes = activeFilters.map(key =>
|
||||
getFilterType(key, 'keyToType')
|
||||
);
|
||||
return filterTypes
|
||||
.filter(({ type }) => !activeFilterTypes.includes(type))
|
||||
.map(({ id, name, type }) => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
options: buildFilterList(this[type], type),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
Object.values(appliedFilters.value).some(value => value !== null)
|
||||
);
|
||||
|
||||
const isAllFilterSelected = computed(() => !filterListMenuItems.value.length);
|
||||
|
||||
const updateURLParams = () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: route.query.from,
|
||||
to: route.query.to,
|
||||
range: route.query.range,
|
||||
filters: {
|
||||
agent_id: appliedFilters.value.assigned_agent_id,
|
||||
inbox_id: appliedFilters.value.inbox_id,
|
||||
team_id: appliedFilters.value.team_id,
|
||||
sla_policy_id: appliedFilters.value.sla_policy_id,
|
||||
label: appliedFilters.value.label_list,
|
||||
},
|
||||
activeFilters() {
|
||||
// Get the active filters from the applied filters
|
||||
// and return the filter name, type and options
|
||||
const activeKey = Object.keys(this.appliedFilters).filter(
|
||||
key => this.appliedFilters[key]
|
||||
);
|
||||
return activeKey.map(key => {
|
||||
const filterType = getFilterType(key, 'keyToType');
|
||||
const item = getActiveFilter(
|
||||
this[filterType],
|
||||
filterType,
|
||||
this.appliedFilters[key]
|
||||
);
|
||||
return {
|
||||
id: item.id,
|
||||
name: filterType === 'labels' ? item.title : item.name,
|
||||
type: filterType,
|
||||
options: buildFilterList(this[filterType], filterType),
|
||||
};
|
||||
});
|
||||
},
|
||||
hasActiveFilters() {
|
||||
return Object.values(this.appliedFilters).some(value => value !== null);
|
||||
},
|
||||
isAllFilterSelected() {
|
||||
return !this.filterListMenuItems.length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addFilter(item) {
|
||||
const { type, id, name } = item;
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
this.appliedFilters[filterKey] = type === 'labels' ? name : id;
|
||||
this.$emit('filterChange', this.appliedFilters);
|
||||
this.resetDropdown();
|
||||
},
|
||||
removeFilter(type) {
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
this.appliedFilters[filterKey] = null;
|
||||
this.$emit('filterChange', this.appliedFilters);
|
||||
},
|
||||
clearAllFilters() {
|
||||
this.appliedFilters = {
|
||||
assigned_agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label_list: null,
|
||||
};
|
||||
this.$emit('filterChange', this.appliedFilters);
|
||||
this.resetDropdown();
|
||||
},
|
||||
showDropdown() {
|
||||
this.showSubDropdownMenu = false;
|
||||
this.showDropdownMenu = !this.showDropdownMenu;
|
||||
},
|
||||
closeDropdown() {
|
||||
this.showDropdownMenu = false;
|
||||
},
|
||||
openActiveFilterDropdown(filterType) {
|
||||
this.closeDropdown();
|
||||
this.activeFilterType = filterType;
|
||||
this.showSubDropdownMenu = !this.showSubDropdownMenu;
|
||||
},
|
||||
closeActiveFilterDropdown() {
|
||||
this.activeFilterType = '';
|
||||
this.showSubDropdownMenu = false;
|
||||
},
|
||||
resetDropdown() {
|
||||
this.closeDropdown();
|
||||
this.closeActiveFilterDropdown();
|
||||
},
|
||||
},
|
||||
});
|
||||
router.replace({ query: params });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
updateURLParams();
|
||||
emit('filterChange', appliedFilters.value);
|
||||
};
|
||||
|
||||
const showDropdown = () => {
|
||||
showSubDropdownMenu.value = false;
|
||||
showDropdownMenu.value = !showDropdownMenu.value;
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
showDropdownMenu.value = false;
|
||||
};
|
||||
|
||||
const openActiveFilterDropdown = filterType => {
|
||||
closeDropdown();
|
||||
activeFilterType.value = filterType;
|
||||
showSubDropdownMenu.value = !showSubDropdownMenu.value;
|
||||
};
|
||||
|
||||
const closeActiveFilterDropdown = () => {
|
||||
activeFilterType.value = '';
|
||||
showSubDropdownMenu.value = false;
|
||||
};
|
||||
|
||||
const resetDropdown = () => {
|
||||
closeDropdown();
|
||||
closeActiveFilterDropdown();
|
||||
};
|
||||
|
||||
const addFilter = item => {
|
||||
const { type, id, name } = item;
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
appliedFilters.value[filterKey] = type === 'labels' ? name : id;
|
||||
emitChange();
|
||||
resetDropdown();
|
||||
};
|
||||
|
||||
const removeFilter = type => {
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
appliedFilters.value[filterKey] = null;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
appliedFilters.value = {
|
||||
assigned_agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label_list: null,
|
||||
};
|
||||
emitChange();
|
||||
resetDropdown();
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
const urlFilters = parseFilterURLParams(route.query);
|
||||
appliedFilters.value.assigned_agent_id = urlFilters.agent_id;
|
||||
appliedFilters.value.inbox_id = urlFilters.inbox_id;
|
||||
appliedFilters.value.team_id = urlFilters.team_id;
|
||||
appliedFilters.value.sla_policy_id = urlFilters.sla_policy_id;
|
||||
appliedFilters.value.label_list = urlFilters.label;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
if (hasActiveFilters.value) {
|
||||
emitChange();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+86
-61
@@ -1,73 +1,98 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import SLAFilter from '../SLA/SLAFilter.vue';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import { DATE_RANGE_OPTIONS } from '../../constants';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import { subDays, fromUnixTime } from 'date-fns';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
parseFilterURLParams,
|
||||
} from '../../helpers/reportFilterHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SLAFilter,
|
||||
},
|
||||
emits: ['filterChange'],
|
||||
const emit = defineEmits(['filterChange']);
|
||||
|
||||
data() {
|
||||
return {
|
||||
selectedDateRange: DATE_RANGE_OPTIONS.LAST_7_DAYS,
|
||||
selectedGroupByFilter: null,
|
||||
customDateRange: [new Date(), new Date()],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
to() {
|
||||
return getUnixEndOfDay(this.customDateRange[1]);
|
||||
},
|
||||
from() {
|
||||
return getUnixStartOfDay(this.customDateRange[0]);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
businessHoursSelected() {
|
||||
this.emitChange();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setInitialRange();
|
||||
},
|
||||
methods: {
|
||||
setInitialRange() {
|
||||
const { offset } = this.selectedDateRange;
|
||||
const fromDate = subDays(new Date(), offset);
|
||||
const from = getUnixStartOfDay(fromDate);
|
||||
const to = getUnixEndOfDay(new Date());
|
||||
this.$emit('filterChange', {
|
||||
from,
|
||||
to,
|
||||
...this.selectedGroupByFilter,
|
||||
});
|
||||
},
|
||||
emitChange() {
|
||||
const { from, to } = this;
|
||||
this.$emit('filterChange', {
|
||||
from,
|
||||
to,
|
||||
...this.selectedGroupByFilter,
|
||||
});
|
||||
},
|
||||
emitFilterChange(params) {
|
||||
this.selectedGroupByFilter = params;
|
||||
this.emitChange();
|
||||
},
|
||||
onDateRangeChange(value) {
|
||||
this.customDateRange = value;
|
||||
this.emitChange();
|
||||
},
|
||||
},
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize from URL params immediately
|
||||
const urlParams = parseReportURLParams(route.query);
|
||||
const initialDateRange =
|
||||
urlParams.from && urlParams.to
|
||||
? [fromUnixTime(urlParams.from), fromUnixTime(urlParams.to)]
|
||||
: [subDays(new Date(), 6), new Date()];
|
||||
|
||||
const selectedDateRange = ref(urlParams.range || 'last7days');
|
||||
const selectedGroupByFilter = ref(null);
|
||||
const customDateRange = ref(initialDateRange);
|
||||
|
||||
const to = computed(() => getUnixEndOfDay(customDateRange.value[1]));
|
||||
const from = computed(() => getUnixStartOfDay(customDateRange.value[0]));
|
||||
|
||||
const updateURLParams = () => {
|
||||
const dateParams = generateReportURLParams({
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
range: selectedDateRange.value,
|
||||
});
|
||||
|
||||
const filterParams = parseFilterURLParams(route.query);
|
||||
const params = { ...dateParams };
|
||||
|
||||
if (filterParams.agent_id) params.agent_id = filterParams.agent_id;
|
||||
if (filterParams.inbox_id) params.inbox_id = filterParams.inbox_id;
|
||||
if (filterParams.team_id) params.team_id = filterParams.team_id;
|
||||
if (filterParams.sla_policy_id)
|
||||
params.sla_policy_id = filterParams.sla_policy_id;
|
||||
if (filterParams.label) params.label = filterParams.label;
|
||||
|
||||
router.replace({ query: params });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
updateURLParams();
|
||||
emit('filterChange', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
...selectedGroupByFilter.value,
|
||||
});
|
||||
};
|
||||
|
||||
const emitFilterChange = params => {
|
||||
selectedGroupByFilter.value = params;
|
||||
emit('filterChange', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
...selectedGroupByFilter.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onDateRangeChange = ([startDate, endDate, rangeType]) => {
|
||||
customDateRange.value = [startDate, endDate];
|
||||
selectedDateRange.value = rangeType;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const setInitialRange = () => {
|
||||
customDateRange.value = [subDays(new Date(), 6), new Date()];
|
||||
emitChange();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!route.query.from || !route.query.to) {
|
||||
setInitialRange();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
|
||||
<woot-date-picker @date-range-changed="onDateRangeChange" />
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
/>
|
||||
<SLAFilter @filter-change="emitFilterChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+50
-7
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import ReportFilterSelector from './FilterSelector.vue';
|
||||
import OverviewReportFilters from './OverviewReportFilters.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Table from 'dashboard/components/table/Table.vue';
|
||||
import { generateFileName } from 'dashboard/helper/downloadHelper';
|
||||
import {
|
||||
@@ -42,6 +44,16 @@ const businessHours = ref(false);
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import SummaryReportLink from './SummaryReportLink.vue';
|
||||
|
||||
const flagMap = {
|
||||
agent: 'isFetchingAgentSummaryReports',
|
||||
inbox: 'isFetchingInboxSummaryReports',
|
||||
team: 'isFetchingTeamSummaryReports',
|
||||
label: 'isFetchingLabelSummaryReports',
|
||||
};
|
||||
|
||||
const uiFlags = useMapGetter('summaryReports/getUIFlags');
|
||||
const isLoading = computed(() => uiFlags.value[flagMap[props.type]] ?? false);
|
||||
|
||||
const rowItems = useMapGetter([props.getterKey]) || [];
|
||||
const reportMetrics = useMapGetter([props.summaryKey]) || [];
|
||||
|
||||
@@ -120,13 +132,26 @@ const tableData = computed(() =>
|
||||
})
|
||||
);
|
||||
|
||||
const fetchAllData = () => {
|
||||
store.dispatch(props.fetchItemsKey);
|
||||
store.dispatch(props.actionKey, {
|
||||
const fetchReportsWithRetry = async () => {
|
||||
const params = {
|
||||
since: from.value,
|
||||
until: to.value,
|
||||
businessHours: businessHours.value,
|
||||
});
|
||||
};
|
||||
try {
|
||||
await store.dispatch(props.actionKey, params);
|
||||
} catch {
|
||||
try {
|
||||
await store.dispatch(props.actionKey, params);
|
||||
} catch {
|
||||
useAlert(t('REPORT.SUMMARY_FETCHING_FAILED'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllData = () => {
|
||||
store.dispatch(props.fetchItemsKey);
|
||||
fetchReportsWithRetry();
|
||||
};
|
||||
|
||||
onMounted(() => fetchAllData());
|
||||
@@ -178,10 +203,28 @@ defineExpose({ downloadReports });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportFilterSelector @filter-change="onFilterChange" />
|
||||
<OverviewReportFilters
|
||||
:disabled="isLoading"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
<div
|
||||
class="flex-1 overflow-auto px-2 py-2 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
class="relative flex-1 overflow-auto px-2 py-2 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
>
|
||||
<Table :table="table" />
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-300 ease-out"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex justify-center pt-[12.5rem] bg-n-solid-1/70 rounded-xl pointer-events-none"
|
||||
>
|
||||
<Spinner :size="32" class="text-n-brand" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+29
-86
@@ -1,31 +1,12 @@
|
||||
<script>
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import ReportFilters from './ReportFilters.vue';
|
||||
import ReportContainer from '../ReportContainer.vue';
|
||||
import { GROUP_BY_FILTER } from '../constants';
|
||||
import { generateFileName } from '../../../../../helper/downloadHelper';
|
||||
import { REPORTS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
|
||||
import ReportHeader from './ReportHeader.vue';
|
||||
|
||||
const GROUP_BY_OPTIONS = {
|
||||
DAY: [{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' }],
|
||||
WEEK: [
|
||||
{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' },
|
||||
{ id: 2, groupByKey: 'REPORT.GROUPING_OPTIONS.WEEK' },
|
||||
],
|
||||
MONTH: [
|
||||
{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' },
|
||||
{ id: 2, groupByKey: 'REPORT.GROUPING_OPTIONS.WEEK' },
|
||||
{ id: 3, groupByKey: 'REPORT.GROUPING_OPTIONS.MONTH' },
|
||||
],
|
||||
YEAR: [
|
||||
{ id: 2, groupByKey: 'REPORT.GROUPING_OPTIONS.WEEK' },
|
||||
{ id: 3, groupByKey: 'REPORT.GROUPING_OPTIONS.MONTH' },
|
||||
{ id: 4, groupByKey: 'REPORT.GROUPING_OPTIONS.YEAR' },
|
||||
],
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ReportHeader,
|
||||
@@ -69,12 +50,19 @@ export default {
|
||||
to: 0,
|
||||
selectedFilter: this.selectedItem,
|
||||
groupBy: GROUP_BY_FILTER[1],
|
||||
groupByfilterItemsList: GROUP_BY_OPTIONS.DAY.map(this.translateOptions),
|
||||
selectedGroupByFilter: null,
|
||||
businessHours: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filterType() {
|
||||
const pluralMap = {
|
||||
agent: 'agents',
|
||||
team: 'teams',
|
||||
inbox: 'inboxes',
|
||||
label: 'labels',
|
||||
};
|
||||
return pluralMap[this.type] || this.type;
|
||||
},
|
||||
filterItemsList() {
|
||||
return this.$store.getters[this.getterKey] || [];
|
||||
},
|
||||
@@ -145,69 +133,29 @@ export default {
|
||||
this.$store.dispatch(dispatchMethods[type], params);
|
||||
}
|
||||
},
|
||||
onDateRangeChange({ from, to, groupBy }) {
|
||||
// do not track filter change on inital load
|
||||
if (this.from !== 0 && this.to !== 0) {
|
||||
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'date',
|
||||
reportType: this.type,
|
||||
});
|
||||
}
|
||||
|
||||
onFilterChange(payload) {
|
||||
const { from, to, businessHours, groupBy } = payload;
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.groupByfilterItemsList = this.fetchFilterItems(groupBy);
|
||||
const filterItems = this.groupByfilterItemsList.filter(
|
||||
item => item.id === this.groupBy.id
|
||||
);
|
||||
if (filterItems.length > 0) {
|
||||
this.selectedGroupByFilter = filterItems[0];
|
||||
this.businessHours = businessHours;
|
||||
|
||||
if (groupBy) {
|
||||
this.groupBy = groupBy;
|
||||
} else {
|
||||
this.selectedGroupByFilter = this.groupByfilterItemsList[0];
|
||||
this.groupBy = GROUP_BY_FILTER[this.selectedGroupByFilter.id];
|
||||
this.groupBy = GROUP_BY_FILTER[1];
|
||||
}
|
||||
this.fetchAllData();
|
||||
},
|
||||
onFilterChange(payload) {
|
||||
if (payload) {
|
||||
this.selectedFilter = payload;
|
||||
this.fetchAllData();
|
||||
}
|
||||
},
|
||||
onGroupByFilterChange(payload) {
|
||||
this.groupBy = GROUP_BY_FILTER[payload.id];
|
||||
this.fetchAllData();
|
||||
|
||||
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'groupBy',
|
||||
filterValue: this.groupBy?.period,
|
||||
reportType: this.type,
|
||||
});
|
||||
},
|
||||
fetchFilterItems(groupBy) {
|
||||
switch (groupBy) {
|
||||
case GROUP_BY_FILTER[2].period:
|
||||
return GROUP_BY_OPTIONS.WEEK.map(this.translateOptions);
|
||||
case GROUP_BY_FILTER[3].period:
|
||||
return GROUP_BY_OPTIONS.MONTH.map(this.translateOptions);
|
||||
case GROUP_BY_FILTER[4].period:
|
||||
return GROUP_BY_OPTIONS.YEAR.map(this.translateOptions);
|
||||
default:
|
||||
return GROUP_BY_OPTIONS.DAY.map(this.translateOptions);
|
||||
// Get filter value directly from filterType key
|
||||
const filterValue = payload[this.filterType];
|
||||
if (filterValue) {
|
||||
this.selectedFilter = Array.isArray(filterValue)
|
||||
? filterValue[0]
|
||||
: filterValue;
|
||||
} else {
|
||||
this.selectedFilter = null;
|
||||
}
|
||||
},
|
||||
translateOptions(opts) {
|
||||
return { id: opts.id, groupBy: this.$t(opts.groupByKey) };
|
||||
},
|
||||
onBusinessHoursToggle(value) {
|
||||
this.businessHours = value;
|
||||
this.fetchAllData();
|
||||
|
||||
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'businessHours',
|
||||
filterValue: value,
|
||||
reportType: this.type,
|
||||
});
|
||||
this.fetchAllData();
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -222,17 +170,12 @@ 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-type="filterType"
|
||||
:selected-item="selectedFilter"
|
||||
@filter-change="onFilterChange"
|
||||
@group-by-filter-change="onGroupByFilterChange"
|
||||
@business-hours-toggle="onBusinessHoursToggle"
|
||||
/>
|
||||
<ReportContainer
|
||||
v-if="filterItemsList.length"
|
||||
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import ReportsFiltersAgents from '../../Filters/Agents.vue';
|
||||
|
||||
const mockStore = createStore({
|
||||
modules: {
|
||||
agents: {
|
||||
namespaced: true,
|
||||
state: {
|
||||
agents: [],
|
||||
},
|
||||
getters: {
|
||||
getAgents: state => state.agents,
|
||||
},
|
||||
actions: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
plugins: [mockStore],
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersAgents.vue', () => {
|
||||
it('emits "agents-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersAgents, mountParams);
|
||||
|
||||
const selectedAgents = [
|
||||
{ id: 1, name: 'Agent 1' },
|
||||
{ id: 2, name: 'Agent 2' },
|
||||
];
|
||||
await wrapper.setData({ selectedOptions: selectedAgents });
|
||||
|
||||
await wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('agentsFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('agentsFilterSelection')[0]).toEqual([
|
||||
selectedAgents,
|
||||
]);
|
||||
});
|
||||
|
||||
it('dispatches the "agents/get" action when the component is mounted', () => {
|
||||
const dispatchSpy = vi.spyOn(mockStore, 'dispatch');
|
||||
|
||||
shallowMount(ReportsFiltersAgents, mountParams);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith('agents/get');
|
||||
});
|
||||
});
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ReportsFiltersDateGroupBy from '../../Filters/DateGroupBy.vue';
|
||||
import { GROUP_BY_OPTIONS } from '../../../constants';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersDateGroupBy.vue', () => {
|
||||
it('emits "on-grouping-change" event when changeFilterSelection is called', () => {
|
||||
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
|
||||
|
||||
const selectedFilter = GROUP_BY_OPTIONS.DAY;
|
||||
wrapper.vm.changeFilterSelection(selectedFilter);
|
||||
|
||||
expect(wrapper.emitted('onGroupingChange')).toBeTruthy();
|
||||
expect(wrapper.emitted('onGroupingChange')[0]).toEqual([selectedFilter]);
|
||||
});
|
||||
|
||||
it('updates currentSelectedFilter when selectedOption is changed', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
|
||||
|
||||
const newSelectedOption = GROUP_BY_OPTIONS.MONTH;
|
||||
await wrapper.setProps({ selectedOption: newSelectedOption });
|
||||
|
||||
expect(wrapper.vm.currentSelectedFilter).toEqual({
|
||||
...newSelectedOption,
|
||||
groupBy: newSelectedOption.translationKey,
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes translatedOptions correctly', () => {
|
||||
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
|
||||
|
||||
const expectedOptions = wrapper.vm.validGroupOptions.map(option => ({
|
||||
...option,
|
||||
groupBy: option.translationKey,
|
||||
}));
|
||||
|
||||
expect(wrapper.vm.translatedOptions).toEqual(expectedOptions);
|
||||
});
|
||||
});
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ReportFiltersDateRange from '../../Filters/DateRange.vue';
|
||||
import { DATE_RANGE_OPTIONS } from '../../../constants';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportFiltersDateRange.vue', () => {
|
||||
it('emits "onRangeChange" event when updateRange is called', () => {
|
||||
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
|
||||
|
||||
const selectedRange = DATE_RANGE_OPTIONS.LAST_7_DAYS;
|
||||
wrapper.vm.updateRange(selectedRange);
|
||||
|
||||
expect(wrapper.emitted('onRangeChange')).toBeTruthy();
|
||||
expect(wrapper.emitted('onRangeChange')[0]).toEqual([selectedRange]);
|
||||
});
|
||||
|
||||
it('initializes options correctly', () => {
|
||||
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
|
||||
|
||||
const expectedIds = Object.values(DATE_RANGE_OPTIONS).map(
|
||||
option => option.id
|
||||
);
|
||||
const receivedIds = wrapper.vm.options.map(option => option.id);
|
||||
|
||||
expect(receivedIds).toEqual(expectedIds);
|
||||
});
|
||||
|
||||
it('initializes selectedOption correctly', () => {
|
||||
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
|
||||
const expectedId = Object.values(DATE_RANGE_OPTIONS)[0].id;
|
||||
expect(wrapper.vm.selectedOption.id).toBe(expectedId);
|
||||
});
|
||||
});
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import ReportsFiltersInboxes from '../../Filters/Inboxes.vue';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersInboxes.vue', () => {
|
||||
let store;
|
||||
let inboxesModule;
|
||||
|
||||
beforeEach(() => {
|
||||
inboxesModule = {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getInboxes: () => () => [
|
||||
{ id: 1, name: 'Inbox 1' },
|
||||
{ id: 2, name: 'Inbox 2' },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
store = createStore({
|
||||
modules: {
|
||||
inboxes: inboxesModule,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches "inboxes/get" action when component is mounted', () => {
|
||||
shallowMount(ReportsFiltersInboxes, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams.global,
|
||||
},
|
||||
});
|
||||
expect(inboxesModule.actions.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits "inbox-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersInboxes, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams.global,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedInbox = { id: 1, name: 'Inbox 1' };
|
||||
await wrapper.setData({ selectedOption: selectedInbox });
|
||||
|
||||
await wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('inboxFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('inboxFilterSelection')[0]).toEqual([selectedInbox]);
|
||||
});
|
||||
});
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import ReportsFiltersLabels from '../../Filters/Labels.vue';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersLabels.vue', () => {
|
||||
let store;
|
||||
let labelsModule;
|
||||
|
||||
beforeEach(() => {
|
||||
labelsModule = {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getLabels: () => () => [
|
||||
{ id: 1, title: 'Label 1', color: 'red' },
|
||||
{ id: 2, title: 'Label 2', color: 'blue' },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
store = createStore({
|
||||
modules: {
|
||||
labels: labelsModule,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches "labels/get" action when component is mounted', () => {
|
||||
shallowMount(ReportsFiltersLabels, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams.global,
|
||||
},
|
||||
});
|
||||
expect(labelsModule.actions.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits "labels-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersLabels, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams.global,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedLabel = { id: 1, title: 'Label 1', color: 'red' };
|
||||
await wrapper.setData({ selectedOption: selectedLabel });
|
||||
|
||||
await wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('labelsFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('labelsFilterSelection')[0]).toEqual([
|
||||
selectedLabel,
|
||||
]);
|
||||
});
|
||||
});
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ReportFiltersRatings from '../../Filters/Ratings.vue';
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportFiltersRatings.vue', () => {
|
||||
it('emits "rating-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportFiltersRatings, {
|
||||
...mountParams,
|
||||
});
|
||||
|
||||
const selectedRating = { value: 1, label: 'Rating 1' };
|
||||
await wrapper.setData({ selectedOption: selectedRating });
|
||||
|
||||
await wrapper.vm.handleInput(selectedRating);
|
||||
|
||||
expect(wrapper.emitted('ratingFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('ratingFilterSelection')[0]).toEqual([
|
||||
selectedRating,
|
||||
]);
|
||||
});
|
||||
|
||||
it('initializes options correctly', () => {
|
||||
const wrapper = shallowMount(ReportFiltersRatings, {
|
||||
...mountParams,
|
||||
});
|
||||
|
||||
const expectedOptions = CSAT_RATINGS.map(option => ({
|
||||
...option,
|
||||
label: option.translationKey,
|
||||
}));
|
||||
|
||||
expect(wrapper.vm.options).toEqual(expectedOptions);
|
||||
});
|
||||
});
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import ReportsFiltersTeams from '../../Filters/Teams.vue';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersTeams.vue', () => {
|
||||
let store;
|
||||
let teamsModule;
|
||||
|
||||
beforeEach(() => {
|
||||
teamsModule = {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getTeams: () => () => [
|
||||
{ id: 1, name: 'Team 1' },
|
||||
{ id: 2, name: 'Team 2' },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
store = createStore({
|
||||
modules: {
|
||||
teams: teamsModule,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches "teams/get" action when component is mounted', () => {
|
||||
shallowMount(ReportsFiltersTeams, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams,
|
||||
},
|
||||
});
|
||||
expect(teamsModule.actions.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits "team-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersTeams, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.setData({ selectedOption: { id: 1, name: 'Team 1' } });
|
||||
await wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('teamFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('teamFilterSelection')[0]).toEqual([
|
||||
{ id: 1, name: 'Team 1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
export const generateReportURLParams = ({
|
||||
from,
|
||||
to,
|
||||
businessHours,
|
||||
groupBy,
|
||||
range,
|
||||
}) => {
|
||||
const params = {};
|
||||
|
||||
// Always include from/to dates
|
||||
if (from) params.from = from;
|
||||
if (to) params.to = to;
|
||||
|
||||
if (businessHours) params.business_hours = 'true';
|
||||
if (groupBy) params.group_by = groupBy;
|
||||
|
||||
// Include range type (last7days, last3months, custom, etc.)
|
||||
if (range) params.range = range;
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
export const parseReportURLParams = query => {
|
||||
const { from, to, business_hours, group_by, range } = query;
|
||||
|
||||
return {
|
||||
from: from ? Number(from) : null,
|
||||
to: to ? Number(to) : null,
|
||||
businessHours: business_hours === 'true',
|
||||
groupBy: group_by ? Number(group_by) : null,
|
||||
range: range || null,
|
||||
};
|
||||
};
|
||||
|
||||
// Parse filter params from URL (agent_id, inbox_id, team_id, sla_policy_id, label, rating)
|
||||
export const parseFilterURLParams = query => {
|
||||
return {
|
||||
agent_id: query.agent_id ? Number(query.agent_id) : null,
|
||||
inbox_id: query.inbox_id ? Number(query.inbox_id) : null,
|
||||
team_id: query.team_id ? Number(query.team_id) : null,
|
||||
sla_policy_id: query.sla_policy_id ? Number(query.sla_policy_id) : null,
|
||||
label: query.label || null,
|
||||
rating: query.rating ? Number(query.rating) : null,
|
||||
};
|
||||
};
|
||||
|
||||
// Generate filter URL params (only include non-null values)
|
||||
export const generateFilterURLParams = filters => {
|
||||
const params = {};
|
||||
if (filters.agent_id) params.agent_id = filters.agent_id;
|
||||
if (filters.inbox_id) params.inbox_id = filters.inbox_id;
|
||||
if (filters.team_id) params.team_id = filters.team_id;
|
||||
if (filters.sla_policy_id) params.sla_policy_id = filters.sla_policy_id;
|
||||
if (filters.label) params.label = filters.label;
|
||||
if (filters.rating) params.rating = filters.rating;
|
||||
return params;
|
||||
};
|
||||
|
||||
// Merge date range and filter params for complete URL
|
||||
export const generateCompleteURLParams = ({
|
||||
from,
|
||||
to,
|
||||
range,
|
||||
filters = {},
|
||||
}) => {
|
||||
const dateParams = generateReportURLParams({ from, to, range });
|
||||
const filterParams = generateFilterURLParams(filters);
|
||||
return { ...dateParams, ...filterParams };
|
||||
};
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
parseFilterURLParams,
|
||||
generateFilterURLParams,
|
||||
generateCompleteURLParams,
|
||||
} from './reportFilterHelper';
|
||||
|
||||
describe('reportFilterHelper', () => {
|
||||
describe('generateReportURLParams', () => {
|
||||
it('generates URL params with from and to dates', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes business hours when true', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: true,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
business_hours: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes business hours when false', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: false,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes group by parameter', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
groupBy: 3,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
group_by: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes range type', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
});
|
||||
});
|
||||
|
||||
it('generates complete URL params with all options', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: true,
|
||||
groupBy: 3,
|
||||
range: 'lastYear',
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
business_hours: 'true',
|
||||
group_by: 3,
|
||||
range: 'lastYear',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseReportURLParams', () => {
|
||||
it('parses from and to dates as numbers', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: false,
|
||||
groupBy: null,
|
||||
range: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses business hours as boolean', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
business_hours: 'true',
|
||||
});
|
||||
|
||||
expect(result.businessHours).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for business hours when not "true"', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
business_hours: 'false',
|
||||
});
|
||||
|
||||
expect(result.businessHours).toBe(false);
|
||||
});
|
||||
|
||||
it('parses group by as number', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
group_by: '3',
|
||||
});
|
||||
|
||||
expect(result.groupBy).toBe(3);
|
||||
});
|
||||
|
||||
it('parses range type', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
range: 'last7days',
|
||||
});
|
||||
|
||||
expect(result.range).toBe('last7days');
|
||||
});
|
||||
|
||||
it('returns null for missing parameters', () => {
|
||||
const result = parseReportURLParams({});
|
||||
|
||||
expect(result).toEqual({
|
||||
from: null,
|
||||
to: null,
|
||||
businessHours: false,
|
||||
groupBy: null,
|
||||
range: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses complete URL params with all options', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
business_hours: 'true',
|
||||
group_by: '3',
|
||||
range: 'lastYear',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: true,
|
||||
groupBy: 3,
|
||||
range: 'lastYear',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles numeric values correctly', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
group_by: 3,
|
||||
});
|
||||
|
||||
expect(result.from).toBe(1738607400);
|
||||
expect(result.to).toBe(1770229799);
|
||||
expect(result.groupBy).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip conversion', () => {
|
||||
it('maintains data integrity through generate and parse cycle', () => {
|
||||
const original = {
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: true,
|
||||
groupBy: 3,
|
||||
range: 'lastYear',
|
||||
};
|
||||
|
||||
const urlParams = generateReportURLParams(original);
|
||||
const parsed = parseReportURLParams(urlParams);
|
||||
|
||||
expect(parsed.from).toBe(original.from);
|
||||
expect(parsed.to).toBe(original.to);
|
||||
expect(parsed.businessHours).toBe(original.businessHours);
|
||||
expect(parsed.groupBy).toBe(original.groupBy);
|
||||
expect(parsed.range).toBe(original.range);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFilterURLParams', () => {
|
||||
it('parses all filter params as numbers', () => {
|
||||
const result = parseFilterURLParams({
|
||||
agent_id: '123',
|
||||
inbox_id: '456',
|
||||
team_id: '789',
|
||||
sla_policy_id: '101',
|
||||
rating: '4',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
agent_id: 123,
|
||||
inbox_id: 456,
|
||||
team_id: 789,
|
||||
sla_policy_id: 101,
|
||||
label: null,
|
||||
rating: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses label as string', () => {
|
||||
const result = parseFilterURLParams({
|
||||
label: 'bug',
|
||||
});
|
||||
|
||||
expect(result.label).toBe('bug');
|
||||
});
|
||||
|
||||
it('returns null for missing parameters', () => {
|
||||
const result = parseFilterURLParams({});
|
||||
|
||||
expect(result).toEqual({
|
||||
agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label: null,
|
||||
rating: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateFilterURLParams', () => {
|
||||
it('includes only non-null filter values', () => {
|
||||
const params = generateFilterURLParams({
|
||||
agent_id: 123,
|
||||
inbox_id: null,
|
||||
team_id: 456,
|
||||
rating: null,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
agent_id: 123,
|
||||
team_id: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes label when present', () => {
|
||||
const params = generateFilterURLParams({
|
||||
label: 'bug',
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
label: 'bug',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty object when all values are null', () => {
|
||||
const params = generateFilterURLParams({
|
||||
agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
});
|
||||
|
||||
expect(params).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCompleteURLParams', () => {
|
||||
it('merges date and filter params', () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
filters: {
|
||||
agent_id: 123,
|
||||
inbox_id: 456,
|
||||
},
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
agent_id: 123,
|
||||
inbox_id: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty filters', () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
filters: {},
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes null filter values', () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
filters: {
|
||||
agent_id: 123,
|
||||
inbox_id: null,
|
||||
team_id: 456,
|
||||
},
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
agent_id: 123,
|
||||
team_id: 456,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export const getters = {
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
},
|
||||
getLabelById: _state => id => {
|
||||
return _state.records.find(record => record.id === Number(id));
|
||||
return _state.records.find(record => record.id === Number(id)) || {};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -141,12 +141,14 @@ describe('Summary Reports Store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
it('should reset uiFlags and rethrow error on failure', async () => {
|
||||
SummaryReportsAPI.getInboxReports.mockRejectedValue(
|
||||
new Error('API Error')
|
||||
);
|
||||
|
||||
await store.actions.fetchInboxSummaryReports({ commit }, {});
|
||||
await expect(
|
||||
store.actions.fetchInboxSummaryReports({ commit }, {})
|
||||
).rejects.toThrow('API Error');
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setUIFlags', {
|
||||
isFetchingInboxSummaryReports: false,
|
||||
|
||||
@@ -28,15 +28,17 @@ async function fetchSummaryReports(type, params, { commit }) {
|
||||
const config = typeMap[type];
|
||||
if (!config) return;
|
||||
|
||||
let error = null;
|
||||
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
|
||||
} catch (e) {
|
||||
error = e;
|
||||
} finally {
|
||||
commit('setUIFlags', { [config.flagKey]: false });
|
||||
}
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export const initialState = {
|
||||
|
||||
@@ -3,6 +3,7 @@ class ConversationReplyEmailJob < ApplicationJob
|
||||
|
||||
def perform(conversation_id, last_queued_id)
|
||||
conversation = Conversation.find(conversation_id)
|
||||
return unless conversation.account.active?
|
||||
|
||||
if conversation.messages.incoming&.last&.content_type == 'incoming_email'
|
||||
ConversationReplyMailer.with(account: conversation.account).reply_without_summary(conversation, last_queued_id).deliver_later
|
||||
|
||||
@@ -13,7 +13,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
end
|
||||
rescue *ExceptionList::IMAP_EXCEPTIONS => e
|
||||
Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}"
|
||||
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError => e
|
||||
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError,
|
||||
Net::IMAP::ResponseParseError, Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError => e
|
||||
Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}"
|
||||
rescue LockAcquisitionError
|
||||
Rails.logger.error "Lock failed for #{channel.inbox.id}"
|
||||
|
||||
@@ -2,6 +2,19 @@ class Internal::SeedAccountJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account)
|
||||
# Reload account to ensure we have fresh data
|
||||
account.reload
|
||||
|
||||
# Perform seeding
|
||||
Seeders::AccountSeeder.new(account: account).perform!
|
||||
|
||||
# Reload again after seeding to ensure cache is fresh
|
||||
account.reload
|
||||
|
||||
Rails.logger.info("Account seeding completed successfully for account_id: #{account.id}")
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Account seeding failed for account_id: #{account.id} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
@@ -54,7 +54,7 @@ class Webhooks::TiktokEventsJob < MutexApplicationJob
|
||||
# Receive real-time notifications if you send a message to a user.
|
||||
def im_send_msg
|
||||
# This can be either an echo message or a message sent directly via tiktok application
|
||||
::Tiktok::MessageService.new(channel: channel, content: content).perform
|
||||
::Tiktok::MessageService.new(channel: channel, content: content, outgoing_echo: true).perform
|
||||
end
|
||||
|
||||
# Receive real-time notifications if a user outside the European Economic Area (EEA), Switzerland, or the UK sends a message to you.
|
||||
|
||||
@@ -9,6 +9,56 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
return
|
||||
end
|
||||
|
||||
if message_echo_event?(params)
|
||||
handle_message_echo(channel, params)
|
||||
else
|
||||
handle_message_events(channel, params)
|
||||
end
|
||||
end
|
||||
|
||||
# Detects if the webhook is an SMB message echo event (message sent from WhatsApp Business app)
|
||||
# This is part of WhatsApp coexistence feature where businesses can respond from both
|
||||
# Chatwoot and the WhatsApp Business app, with messages synced to Chatwoot.
|
||||
#
|
||||
# Regular message payload (field: "messages"):
|
||||
# {
|
||||
# "entry": [{
|
||||
# "changes": [{
|
||||
# "field": "messages",
|
||||
# "value": {
|
||||
# "contacts": [{ "wa_id": "919745786257", "profile": { "name": "Customer" } }],
|
||||
# "messages": [{ "from": "919745786257", "id": "wamid...", "text": { "body": "Hello" } }]
|
||||
# }
|
||||
# }]
|
||||
# }]
|
||||
# }
|
||||
#
|
||||
# Echo message payload (field: "smb_message_echoes"):
|
||||
# {
|
||||
# "entry": [{
|
||||
# "changes": [{
|
||||
# "field": "smb_message_echoes",
|
||||
# "value": {
|
||||
# "message_echoes": [{ "from": "971545296927", "to": "919745786257", "id": "wamid...", "text": { "body": "Hi" } }]
|
||||
# }
|
||||
# }]
|
||||
# }]
|
||||
# }
|
||||
#
|
||||
# Key differences:
|
||||
# - field: "smb_message_echoes" instead of "messages"
|
||||
# - message_echoes[] instead of messages[]
|
||||
# - "from" is the business number, "to" is the contact (reversed from regular messages)
|
||||
# - No "contacts" array in echo payload
|
||||
def message_echo_event?(params)
|
||||
params.dig(:entry, 0, :changes, 0, :field) == 'smb_message_echoes'
|
||||
end
|
||||
|
||||
def handle_message_echo(channel, params)
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
|
||||
end
|
||||
|
||||
def handle_message_events(channel, params)
|
||||
case channel.provider
|
||||
when 'whatsapp_cloud'
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform
|
||||
|
||||
@@ -38,6 +38,7 @@ class ConversationReplyMailer < ApplicationMailer
|
||||
return unless smtp_config_set_or_development?
|
||||
|
||||
init_conversation_attributes(message.conversation)
|
||||
|
||||
@message = message
|
||||
prepare_mail(true)
|
||||
end
|
||||
|
||||
@@ -29,6 +29,7 @@ class Account < ApplicationRecord
|
||||
include Featurable
|
||||
include CacheKeys
|
||||
include CaptainFeaturable
|
||||
include AccountEmailRateLimitable
|
||||
|
||||
SETTINGS_PARAMS_SCHEMA = {
|
||||
'type': 'object',
|
||||
|
||||
@@ -41,8 +41,8 @@ class AutomationRule < ApplicationRecord
|
||||
|
||||
def actions_attributes
|
||||
%w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation
|
||||
send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript
|
||||
add_private_note].freeze
|
||||
send_attachment change_status resolve_conversation open_conversation pending_conversation snooze_conversation change_priority
|
||||
send_email_transcript add_private_note].freeze
|
||||
end
|
||||
|
||||
def file_base_data
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
module AccountEmailRateLimitable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
OUTBOUND_EMAIL_TTL = 25.hours.to_i
|
||||
EMAIL_LIMIT_CONFIG_KEY = 'ACCOUNT_EMAILS_LIMIT'.freeze
|
||||
|
||||
def email_rate_limit
|
||||
account_limit || global_limit || default_limit
|
||||
end
|
||||
|
||||
def emails_sent_today
|
||||
Redis::Alfred.get(email_count_cache_key).to_i
|
||||
end
|
||||
|
||||
def within_email_rate_limit?
|
||||
return true if emails_sent_today < email_rate_limit
|
||||
|
||||
Rails.logger.warn("Account #{id} reached daily email rate limit of #{email_rate_limit}. Sent: #{emails_sent_today}")
|
||||
false
|
||||
end
|
||||
|
||||
def increment_email_sent_count
|
||||
Redis::Alfred.incr(email_count_cache_key).tap do |count|
|
||||
Redis::Alfred.expire(email_count_cache_key, OUTBOUND_EMAIL_TTL) if count == 1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def email_count_cache_key
|
||||
@email_count_cache_key ||= format(
|
||||
Redis::Alfred::ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY,
|
||||
account_id: id,
|
||||
date: Time.zone.today.to_s
|
||||
)
|
||||
end
|
||||
|
||||
def account_limit
|
||||
self[:limits]&.dig('emails')&.to_i
|
||||
end
|
||||
|
||||
def global_limit
|
||||
GlobalConfig.get(EMAIL_LIMIT_CONFIG_KEY)[EMAIL_LIMIT_CONFIG_KEY]&.to_i
|
||||
end
|
||||
|
||||
def default_limit
|
||||
ChatwootApp.max_limit.to_i
|
||||
end
|
||||
end
|
||||
@@ -344,10 +344,11 @@ class Message < ApplicationRecord
|
||||
# if the sender is not a user, it's not a human response
|
||||
# if automation rule id is present, it's not a human response
|
||||
# if campaign id is present, it's not a human response
|
||||
# external echo messages are responses sent from the native app (WhatsApp Business, Instagram)
|
||||
outgoing? &&
|
||||
content_attributes['automation_rule_id'].blank? &&
|
||||
additional_attributes['campaign_id'].blank? &&
|
||||
sender.is_a?(User)
|
||||
(sender.is_a?(User) || content_attributes['external_echo'].present?)
|
||||
end
|
||||
|
||||
def bot_response?
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class AccountDeletionService
|
||||
SOFT_DELETE_EMAIL_DOMAIN = '@chatwoot-deleted.invalid'.freeze
|
||||
|
||||
attr_reader :account, :soft_deleted_users
|
||||
|
||||
def initialize(account:)
|
||||
@@ -25,15 +27,11 @@ class AccountDeletionService
|
||||
|
||||
def soft_delete_orphaned_users
|
||||
account.users.each do |user|
|
||||
# Find all account_users for this user excluding the current account
|
||||
other_accounts = user.account_users.where.not(account_id: account.id).count
|
||||
# Skip users who are still associated with another account.
|
||||
next if user.account_users.where.not(account_id: account.id).exists?
|
||||
|
||||
# If user has no other accounts, soft delete them
|
||||
next unless other_accounts.zero?
|
||||
|
||||
# Soft delete user by appending -deleted.com to email
|
||||
original_email = user.email
|
||||
user.email = "#{original_email}-deleted.com"
|
||||
user.email = soft_deleted_email_for(user)
|
||||
user.skip_reconfirmation!
|
||||
user.save!
|
||||
|
||||
@@ -47,4 +45,8 @@ class AccountDeletionService
|
||||
Rails.logger.info("Soft deleted user #{user.id} with email #{original_email}")
|
||||
end
|
||||
end
|
||||
|
||||
def soft_deleted_email_for(user)
|
||||
"#{user.id}#{SOFT_DELETE_EMAIL_DOMAIN}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,6 +22,10 @@ class ActionService
|
||||
@conversation.open!
|
||||
end
|
||||
|
||||
def pending_conversation(_params)
|
||||
@conversation.pending!
|
||||
end
|
||||
|
||||
def change_status(status)
|
||||
@conversation.update!(status: status[0])
|
||||
end
|
||||
@@ -74,8 +78,11 @@ class ActionService
|
||||
emails = emails[0].gsub(/\s+/, '').split(',')
|
||||
|
||||
emails.each do |email|
|
||||
break unless @account.within_email_rate_limit?
|
||||
|
||||
email = parse_email_variables(@conversation, email)
|
||||
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, email)&.deliver_later
|
||||
@account.increment_email_sent_count
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -58,7 +58,10 @@ class AutomationRules::ActionService < ActionService
|
||||
teams = Team.where(id: params[0][:team_ids])
|
||||
|
||||
teams.each do |team|
|
||||
break unless @account.within_email_rate_limit?
|
||||
|
||||
TeamNotifications::AutomationNotificationMailer.conversation_creation(@conversation, team, params[0][:message])&.deliver_now
|
||||
@account.increment_email_sent_count
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,6 +13,7 @@ class Messages::SendEmailNotificationService
|
||||
return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i)
|
||||
|
||||
ConversationReplyEmailJob.set(wait: 2.minutes).perform_later(conversation.id, message.id)
|
||||
message.account.increment_email_sent_count
|
||||
end
|
||||
|
||||
private
|
||||
@@ -20,6 +21,7 @@ class Messages::SendEmailNotificationService
|
||||
def should_send_email_notification?
|
||||
return false unless message.email_notifiable_message?
|
||||
return false if message.conversation.contact.email.blank?
|
||||
return false unless message.account.within_email_rate_limit?
|
||||
|
||||
email_reply_enabled?
|
||||
end
|
||||
|
||||
@@ -7,15 +7,22 @@ class Notification::EmailNotificationService
|
||||
# don't send emails if user is not confirmed
|
||||
return if notification.user.confirmed_at.nil?
|
||||
return unless user_subscribed_to_notification?
|
||||
return unless notification.account.within_email_rate_limit?
|
||||
|
||||
# TODO : Clean up whatever happening over here
|
||||
# Segregate the mailers properly
|
||||
AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send(notification
|
||||
.notification_type.to_s, notification.primary_actor, notification.user, notification.secondary_actor).deliver_later
|
||||
send_notification_email
|
||||
notification.account.increment_email_sent_count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# TODO : Clean up whatever happening over here
|
||||
# Segregate the mailers properly
|
||||
def send_notification_email
|
||||
AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send(
|
||||
notification.notification_type.to_s, notification.primary_actor, notification.user, notification.secondary_actor
|
||||
).deliver_later
|
||||
end
|
||||
|
||||
def user_subscribed_to_notification?
|
||||
notification_setting = notification.user.notification_settings.find_by(account_id: notification.account.id)
|
||||
return true if notification_setting.public_send("email_#{notification.notification_type}?")
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
class Tiktok::MessageService
|
||||
include Tiktok::MessagingHelpers
|
||||
|
||||
pattr_initialize [:channel!, :content!]
|
||||
pattr_initialize [:channel!, :content!, :outgoing_echo]
|
||||
|
||||
def perform
|
||||
if outgoing_message?
|
||||
# Skip processing echo messages
|
||||
message = find_message(tt_conversation_id, tt_message_id)
|
||||
return if message.present?
|
||||
end
|
||||
@@ -39,7 +38,7 @@ class Tiktok::MessageService
|
||||
updated_at: tt_message_time
|
||||
)
|
||||
|
||||
message.sender = contact_inbox.contact if incoming_message?
|
||||
message.sender = contact_inbox.contact if incoming_message? && !outgoing_echo
|
||||
message.status = :delivered if outgoing_message?
|
||||
|
||||
create_message_attachments(message)
|
||||
@@ -91,6 +90,7 @@ class Tiktok::MessageService
|
||||
attributes = {}
|
||||
attributes[:in_reply_to_external_id] = tt_referenced_message_id if tt_referenced_message_id
|
||||
attributes[:is_unsupported] = true unless supported_message?
|
||||
attributes[:external_echo] = true if outgoing_echo
|
||||
attributes
|
||||
end
|
||||
|
||||
|
||||
@@ -61,16 +61,36 @@ class Whatsapp::FacebookApiClient
|
||||
end
|
||||
|
||||
def subscribe_waba_webhook(waba_id, callback_url, verify_token)
|
||||
# Step 1: Subscribe app to WABA first (required before override)
|
||||
# Meta requires the app to be subscribed before using override_callback_uri
|
||||
# See: https://github.com/chatwoot/chatwoot/issues/13097
|
||||
subscribe_app_to_waba(waba_id)
|
||||
|
||||
# Step 2: Override callback URL for this specific WABA
|
||||
override_waba_callback(waba_id, callback_url, verify_token)
|
||||
end
|
||||
|
||||
def subscribe_app_to_waba(waba_id)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
headers: request_headers
|
||||
)
|
||||
|
||||
handle_response(response, 'App subscription to WABA failed')
|
||||
end
|
||||
|
||||
def override_waba_callback(waba_id, callback_url, verify_token)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
headers: request_headers,
|
||||
body: {
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: verify_token
|
||||
verify_token: verify_token,
|
||||
subscribed_fields: %w[messages smb_message_echoes]
|
||||
}.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'Webhook subscription failed')
|
||||
handle_response(response, 'Webhook callback override failed')
|
||||
end
|
||||
|
||||
def unsubscribe_waba_webhook(waba_id)
|
||||
|
||||
@@ -4,18 +4,23 @@
|
||||
class Whatsapp::IncomingMessageBaseService
|
||||
include ::Whatsapp::IncomingMessageServiceHelpers
|
||||
|
||||
pattr_initialize [:inbox!, :params!]
|
||||
pattr_initialize [:inbox!, :params!, :outgoing_echo]
|
||||
|
||||
def perform
|
||||
processed_params
|
||||
|
||||
if processed_params.try(:[], :statuses).present?
|
||||
process_statuses
|
||||
elsif processed_params.try(:[], :messages).present?
|
||||
elsif messages_data.present?
|
||||
process_messages
|
||||
end
|
||||
end
|
||||
|
||||
# Returns messages array for both regular messages and echo events
|
||||
def messages_data
|
||||
@processed_params&.dig(:messages) || @processed_params&.dig(:message_echoes)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_messages
|
||||
@@ -26,7 +31,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
|
||||
# business manager account. While we have not found the core reason yet, the following line ensure that
|
||||
# there are no duplicate messages created.
|
||||
return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process?
|
||||
return if find_message_by_source_id(messages_data.first[:id]) || message_under_process?
|
||||
|
||||
cache_message_source_id_in_redis
|
||||
set_contact
|
||||
@@ -57,7 +62,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
end
|
||||
|
||||
def create_messages
|
||||
message = @processed_params[:messages].first
|
||||
message = messages_data.first
|
||||
log_error(message) && return if error_webhook_event?(message)
|
||||
|
||||
process_in_reply_to(message)
|
||||
@@ -67,20 +72,44 @@ class Whatsapp::IncomingMessageBaseService
|
||||
|
||||
def create_contact_messages(message)
|
||||
message['contacts'].each do |contact|
|
||||
create_message(contact)
|
||||
# Pass source_id from parent message since contact objects don't have :id
|
||||
create_message(contact, source_id: message[:id])
|
||||
attach_contact(contact)
|
||||
@message.save!
|
||||
end
|
||||
end
|
||||
|
||||
def create_regular_message(message)
|
||||
create_message(message)
|
||||
create_message(message, source_id: message[:id])
|
||||
attach_files
|
||||
attach_location if message_type == 'location'
|
||||
@message.save!
|
||||
end
|
||||
|
||||
def set_contact
|
||||
if outgoing_echo
|
||||
set_contact_from_echo
|
||||
else
|
||||
set_contact_from_message
|
||||
end
|
||||
end
|
||||
|
||||
def set_contact_from_echo
|
||||
# For echo messages, contact phone is in the 'to' field
|
||||
phone_number = messages_data.first[:to]
|
||||
waid = processed_waid(phone_number)
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: waid,
|
||||
inbox: inbox,
|
||||
contact_attributes: { name: "+#{phone_number}", phone_number: "+#{phone_number}" }
|
||||
).perform
|
||||
|
||||
@contact_inbox = contact_inbox
|
||||
@contact = contact_inbox.contact
|
||||
end
|
||||
|
||||
def set_contact_from_message
|
||||
contact_params = @processed_params[:contacts]&.first
|
||||
return if contact_params.blank?
|
||||
|
||||
@@ -89,7 +118,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: waid,
|
||||
inbox: inbox,
|
||||
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{@processed_params[:messages].first[:from]}" }
|
||||
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{messages_data.first[:from]}" }
|
||||
).perform
|
||||
|
||||
@contact_inbox = contact_inbox
|
||||
@@ -115,7 +144,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
def attach_files
|
||||
return if %w[text button interactive location contacts].include?(message_type)
|
||||
|
||||
attachment_payload = @processed_params[:messages].first[message_type.to_sym]
|
||||
attachment_payload = messages_data.first[message_type.to_sym]
|
||||
@message.content ||= attachment_payload[:caption]
|
||||
|
||||
attachment_file = download_attachment_file(attachment_payload)
|
||||
@@ -133,7 +162,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
end
|
||||
|
||||
def attach_location
|
||||
location = @processed_params[:messages].first['location']
|
||||
location = messages_data.first['location']
|
||||
location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
|
||||
@message.attachments.new(
|
||||
account_id: @message.account_id,
|
||||
@@ -145,15 +174,20 @@ class Whatsapp::IncomingMessageBaseService
|
||||
)
|
||||
end
|
||||
|
||||
def create_message(message)
|
||||
def create_message(message, source_id: nil)
|
||||
content_attrs = outgoing_echo ? { external_echo: true } : {}
|
||||
content_attrs[:in_reply_to_external_id] = @in_reply_to_external_id if @in_reply_to_external_id.present?
|
||||
|
||||
@message = @conversation.messages.build(
|
||||
content: message_content(message),
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
message_type: :incoming,
|
||||
sender: @contact,
|
||||
source_id: message[:id].to_s,
|
||||
in_reply_to_external_id: @in_reply_to_external_id
|
||||
message_type: outgoing_echo ? :outgoing : :incoming,
|
||||
# Set status to :delivered for echo messages to prevent SendReplyJob from trying to send them
|
||||
status: outgoing_echo ? :delivered : :sent,
|
||||
sender: outgoing_echo ? nil : @contact,
|
||||
source_id: (source_id || message[:id]).to_s,
|
||||
content_attributes: content_attrs
|
||||
)
|
||||
end
|
||||
|
||||
@@ -189,7 +223,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
end
|
||||
|
||||
def contact_name_matches_phone_number?
|
||||
phone_number = "+#{@processed_params[:messages].first[:from]}"
|
||||
phone_number = "+#{messages_data.first[:from]}"
|
||||
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
|
||||
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||
end
|
||||
|
||||
@@ -21,7 +21,7 @@ module Whatsapp::IncomingMessageServiceHelpers
|
||||
end
|
||||
|
||||
def message_type
|
||||
@processed_params[:messages].first[:type]
|
||||
messages_data.first[:type]
|
||||
end
|
||||
|
||||
def message_content(message)
|
||||
@@ -70,19 +70,19 @@ module Whatsapp::IncomingMessageServiceHelpers
|
||||
end
|
||||
|
||||
def message_under_process?
|
||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
|
||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
|
||||
Redis::Alfred.get(key)
|
||||
end
|
||||
|
||||
def cache_message_source_id_in_redis
|
||||
return if @processed_params.try(:[], :messages).blank?
|
||||
return if messages_data.blank?
|
||||
|
||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
|
||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
|
||||
::Redis::Alfred.setex(key, true)
|
||||
end
|
||||
|
||||
def clear_message_source_id_from_redis
|
||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
|
||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
|
||||
::Redis::Alfred.delete(key)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,7 +13,8 @@ end
|
||||
|
||||
# Velma : Determined protector
|
||||
# used in rack attack
|
||||
$velma = ConnectionPool.new(size: 5, timeout: 1) do
|
||||
velma_size = ENV.fetch('REDIS_VELMA_SIZE', 10)
|
||||
$velma = ConnectionPool.new(size: velma_size, timeout: 1) do
|
||||
config = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
|
||||
Redis::Namespace.new('velma', redis: config, warning: true)
|
||||
end
|
||||
|
||||
@@ -107,6 +107,16 @@
|
||||
value:
|
||||
description: 'The support email address for your installation'
|
||||
locked: false
|
||||
- name: ACCOUNT_EMAILS_LIMIT
|
||||
display_title: 'Account Email Sending Limit (Daily)'
|
||||
description: 'Maximum number of non-channel emails an account can send per day'
|
||||
value: 100
|
||||
locked: false
|
||||
- name: ACCOUNT_EMAILS_PLAN_LIMITS
|
||||
display_title: 'Account Email Plan Limits (Daily)'
|
||||
description: 'Per-plan daily email sending limits as JSON'
|
||||
value:
|
||||
type: code
|
||||
# ------- End of Email Related Config ------- #
|
||||
|
||||
# ------- Facebook Channel Related Config ------- #
|
||||
|
||||
@@ -444,6 +444,9 @@ Rails.application.routes.draw do
|
||||
get :conversations_summary
|
||||
get :conversation_traffic
|
||||
get :bot_metrics
|
||||
get :inbox_label_matrix
|
||||
get :first_response_time_distribution
|
||||
get :outgoing_messages_count
|
||||
end
|
||||
end
|
||||
resource :year_in_review, only: [:show]
|
||||
|
||||
+7
-6
@@ -33,12 +33,13 @@ remove_stale_redis_keys_job.rb:
|
||||
class: 'Internal::RemoveStaleRedisKeysJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
#executed daily at 0430 UTC
|
||||
# which will be IST 10:00 AM
|
||||
process_stale_contacts_job:
|
||||
cron: '30 04 * * *'
|
||||
class: 'Internal::ProcessStaleContactsJob'
|
||||
queue: housekeeping
|
||||
# DISABLED: investigating if this job is the source of orphan conversations
|
||||
# #executed daily at 0430 UTC
|
||||
# # which will be IST 10:00 AM
|
||||
# process_stale_contacts_job:
|
||||
# cron: '30 04 * * *'
|
||||
# class: 'Internal::ProcessStaleContactsJob'
|
||||
# queue: housekeeping
|
||||
|
||||
# executed daily at 0100 UTC
|
||||
# to delete accounts marked for deletion
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class AddIndexToReportingEventsForResponseDistribution < ActiveRecord::Migration[7.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_index :reporting_events,
|
||||
[:account_id, :name, :inbox_id, :created_at],
|
||||
name: 'index_reporting_events_for_response_distribution',
|
||||
algorithm: :concurrently,
|
||||
if_not_exists: true
|
||||
end
|
||||
end
|
||||
+2
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -1115,6 +1115,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
|
||||
t.datetime "event_start_time", precision: nil
|
||||
t.datetime "event_end_time", precision: nil
|
||||
t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at"
|
||||
t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution"
|
||||
t.index ["account_id"], name: "index_reporting_events_on_account_id"
|
||||
t.index ["conversation_id"], name: "index_reporting_events_on_conversation_id"
|
||||
t.index ["created_at"], name: "index_reporting_events_on_created_at"
|
||||
|
||||
+698
-45
@@ -18,80 +18,733 @@ unless Rails.env.production?
|
||||
GlobalConfig.clear_cache
|
||||
|
||||
account = Account.create!(
|
||||
name: 'Acme Inc'
|
||||
name: 'Paperlayer'
|
||||
)
|
||||
|
||||
secondary_account = Account.create!(
|
||||
name: 'Acme Org'
|
||||
)
|
||||
|
||||
user = User.new(name: 'John', email: 'john@acme.inc', password: 'Password1!', type: 'SuperAdmin')
|
||||
# Create Admin
|
||||
user = User.new(name: 'Sarah Johnson', email: 'sarah@paperlayer.com', password: 'Password1!', type: 'SuperAdmin')
|
||||
user.skip_confirmation!
|
||||
user.save!
|
||||
|
||||
AccountUser.create!(
|
||||
account_id: account.id,
|
||||
user_id: user.id,
|
||||
role: :administrator
|
||||
# Create Sales Team
|
||||
sales_lead = User.new(name: 'Marcus Chen', email: 'marcus@paperlayer.com', password: 'Password1!')
|
||||
sales_lead.skip_confirmation!
|
||||
sales_lead.save!
|
||||
|
||||
sales_agent_1 = User.new(name: 'Emily Rodriguez', email: 'emily@paperlayer.com', password: 'Password1!')
|
||||
sales_agent_1.skip_confirmation!
|
||||
sales_agent_1.save!
|
||||
|
||||
sales_agent_2 = User.new(name: 'David Kim', email: 'david@paperlayer.com', password: 'Password1!')
|
||||
sales_agent_2.skip_confirmation!
|
||||
sales_agent_2.save!
|
||||
|
||||
# Create Support Team
|
||||
support_lead = User.new(name: 'Jennifer Williams', email: 'jennifer@paperlayer.com', password: 'Password1!')
|
||||
support_lead.skip_confirmation!
|
||||
support_lead.save!
|
||||
|
||||
support_agent_1 = User.new(name: 'Alex Thompson', email: 'alex@paperlayer.com', password: 'Password1!')
|
||||
support_agent_1.skip_confirmation!
|
||||
support_agent_1.save!
|
||||
|
||||
support_agent_2 = User.new(name: 'Rachel Green', email: 'rachel@paperlayer.com', password: 'Password1!')
|
||||
support_agent_2.skip_confirmation!
|
||||
support_agent_2.save!
|
||||
|
||||
# Create Operations Team
|
||||
operations_lead = User.new(name: 'Tom Anderson', email: 'tom@paperlayer.com', password: 'Password1!')
|
||||
operations_lead.skip_confirmation!
|
||||
operations_lead.save!
|
||||
|
||||
operations_agent = User.new(name: 'Lisa Martinez', email: 'lisa@paperlayer.com', password: 'Password1!')
|
||||
operations_agent.skip_confirmation!
|
||||
operations_agent.save!
|
||||
|
||||
# Create Account Users
|
||||
AccountUser.create!(account_id: account.id, user_id: user.id, role: :administrator)
|
||||
AccountUser.create!(account_id: account.id, user_id: sales_lead.id, role: :administrator)
|
||||
AccountUser.create!(account_id: account.id, user_id: sales_agent_1.id, role: :agent)
|
||||
AccountUser.create!(account_id: account.id, user_id: sales_agent_2.id, role: :agent)
|
||||
AccountUser.create!(account_id: account.id, user_id: support_lead.id, role: :administrator)
|
||||
AccountUser.create!(account_id: account.id, user_id: support_agent_1.id, role: :agent)
|
||||
AccountUser.create!(account_id: account.id, user_id: support_agent_2.id, role: :agent)
|
||||
AccountUser.create!(account_id: account.id, user_id: operations_lead.id, role: :administrator)
|
||||
AccountUser.create!(account_id: account.id, user_id: operations_agent.id, role: :agent)
|
||||
AccountUser.create!(account_id: secondary_account.id, user_id: user.id, role: :administrator)
|
||||
|
||||
# Create Teams
|
||||
sales_team = Team.create!(
|
||||
account: account,
|
||||
name: 'Sales Team',
|
||||
description: 'Handles all sales inquiries, quotes, and bulk orders',
|
||||
allow_auto_assign: true
|
||||
)
|
||||
|
||||
AccountUser.create!(
|
||||
account_id: secondary_account.id,
|
||||
user_id: user.id,
|
||||
role: :administrator
|
||||
support_team = Team.create!(
|
||||
account: account,
|
||||
name: 'Customer Support',
|
||||
description: 'Handles customer service, product questions, and order issues',
|
||||
allow_auto_assign: true
|
||||
)
|
||||
|
||||
web_widget = Channel::WebWidget.create!(account: account, website_url: 'https://acme.inc')
|
||||
operations_team = Team.create!(
|
||||
account: account,
|
||||
name: 'Operations',
|
||||
description: 'Handles shipping, logistics, and order fulfillment',
|
||||
allow_auto_assign: true
|
||||
)
|
||||
|
||||
inbox = Inbox.create!(channel: web_widget, account: account, name: 'Acme Support')
|
||||
# Add members to Sales Team
|
||||
TeamMember.create!(team: sales_team, user: sales_lead)
|
||||
TeamMember.create!(team: sales_team, user: sales_agent_1)
|
||||
TeamMember.create!(team: sales_team, user: sales_agent_2)
|
||||
|
||||
# Add members to Support Team
|
||||
TeamMember.create!(team: support_team, user: support_lead)
|
||||
TeamMember.create!(team: support_team, user: support_agent_1)
|
||||
TeamMember.create!(team: support_team, user: support_agent_2)
|
||||
|
||||
# Add members to Operations Team
|
||||
TeamMember.create!(team: operations_team, user: operations_lead)
|
||||
TeamMember.create!(team: operations_team, user: operations_agent)
|
||||
|
||||
# Create Inboxes
|
||||
web_widget = Channel::WebWidget.create!(account: account, website_url: 'https://paperlayer.com')
|
||||
inbox = Inbox.create!(channel: web_widget, account: account, name: 'Website Chat')
|
||||
InboxMember.create!(user: user, inbox: inbox)
|
||||
InboxMember.create!(user: sales_lead, inbox: inbox)
|
||||
InboxMember.create!(user: sales_agent_1, inbox: inbox)
|
||||
InboxMember.create!(user: sales_agent_2, inbox: inbox)
|
||||
InboxMember.create!(user: support_lead, inbox: inbox)
|
||||
InboxMember.create!(user: support_agent_1, inbox: inbox)
|
||||
InboxMember.create!(user: support_agent_2, inbox: inbox)
|
||||
InboxMember.create!(user: operations_lead, inbox: inbox)
|
||||
InboxMember.create!(user: operations_agent, inbox: inbox)
|
||||
|
||||
contact_inbox = ContactInboxWithContactBuilder.new(
|
||||
source_id: user.id,
|
||||
sales_inbox = Inbox.create!(channel: Channel::WebWidget.create!(account: account, website_url: 'https://paperlayer.com/sales'), account: account,
|
||||
name: 'Sales Inquiries')
|
||||
InboxMember.create!(user: user, inbox: sales_inbox)
|
||||
InboxMember.create!(user: sales_lead, inbox: sales_inbox)
|
||||
InboxMember.create!(user: sales_agent_1, inbox: sales_inbox)
|
||||
InboxMember.create!(user: sales_agent_2, inbox: sales_inbox)
|
||||
|
||||
email_channel = Channel::Email.create!(
|
||||
account: account,
|
||||
email: 'support@paperlayer.com',
|
||||
forward_to_email: 'support@paperlayer.com'
|
||||
)
|
||||
support_inbox = Inbox.create!(channel: email_channel, account: account, name: 'Email Support')
|
||||
InboxMember.create!(user: support_lead, inbox: support_inbox)
|
||||
InboxMember.create!(user: support_agent_1, inbox: support_inbox)
|
||||
InboxMember.create!(user: support_agent_2, inbox: support_inbox)
|
||||
|
||||
# Create Labels
|
||||
bulk_order_label = Label.create!(account: account, title: 'bulk-order', description: 'Large volume paper orders', color: '#00C875',
|
||||
show_on_sidebar: true)
|
||||
custom_print_label = Label.create!(account: account, title: 'custom-printing', description: 'Custom letterhead and printing requests',
|
||||
color: '#784BD1', show_on_sidebar: true)
|
||||
urgent_label = Label.create!(account: account, title: 'urgent', description: 'Rush delivery needed', color: '#E2445C', show_on_sidebar: true)
|
||||
pricing_label = Label.create!(account: account, title: 'pricing-inquiry', description: 'Quote requests and pricing questions', color: '#FDAB3D',
|
||||
show_on_sidebar: true)
|
||||
delivery_label = Label.create!(account: account, title: 'delivery-issue', description: 'Shipping and delivery concerns', color: '#9CD326',
|
||||
show_on_sidebar: true)
|
||||
product_label = Label.create!(account: account, title: 'product-question', description: 'Questions about paper types and products',
|
||||
color: '#579BFC', show_on_sidebar: true)
|
||||
|
||||
# Create Canned Responses
|
||||
CannedResponse.create!(account: account, short_code: 'welcome',
|
||||
content: 'Thank you for contacting Paperlayer! We\'re committed to providing the highest quality paper products for your business. How can I help you today?')
|
||||
CannedResponse.create!(account: account, short_code: 'bulk',
|
||||
content: 'For bulk orders over 100 reams, we offer competitive wholesale pricing. Let me get you a custom quote. Could you share your estimated monthly paper needs?')
|
||||
CannedResponse.create!(account: account, short_code: 'pricing',
|
||||
content: 'Our standard pricing for premium white copy paper (20lb, 8.5x11) is $45 per case (10 reams). We offer volume discounts starting at 50 cases.')
|
||||
CannedResponse.create!(account: account, short_code: 'delivery',
|
||||
content: 'We offer same-day delivery for local orders (within 50 miles) placed before noon, and 2-3 business days for standard shipping. Rush delivery is available for an additional fee.')
|
||||
CannedResponse.create!(account: account, short_code: 'custom',
|
||||
content: 'We provide custom letterhead printing services with a minimum order of 5 reams. Typical turnaround is 5-7 business days. Would you like to discuss your design requirements?')
|
||||
CannedResponse.create!(account: account, short_code: 'stock',
|
||||
content: 'We carry a full range of paper products: copy paper (20lb-32lb), cardstock, colored paper, glossy photo paper, and specialty papers. What type are you interested in?')
|
||||
CannedResponse.create!(account: account, short_code: 'closing',
|
||||
content: 'Thank you for choosing Paperlayer! Is there anything else I can help you with today?')
|
||||
CannedResponse.create!(account: account, short_code: 'followup',
|
||||
content: 'I wanted to follow up on your recent inquiry. Have you had a chance to review our quote?')
|
||||
CannedResponse.create!(account: account, short_code: 'tracking',
|
||||
content: 'I can help you track your order. Could you please provide your order number?')
|
||||
CannedResponse.create!(account: account, short_code: 'samples',
|
||||
content: 'We\'d be happy to send you paper samples! What types of paper are you interested in testing?')
|
||||
|
||||
# Create Macros
|
||||
Macro.create!(
|
||||
account: account,
|
||||
name: 'Handle Bulk Order Inquiry',
|
||||
visibility: :global,
|
||||
created_by: user,
|
||||
actions: [
|
||||
{ 'action_name' => 'add_label', 'action_params' => [bulk_order_label.title] },
|
||||
{ 'action_name' => 'assign_team', 'action_params' => [sales_team.id] },
|
||||
{ 'action_name' => 'send_message',
|
||||
'action_params' => ['For bulk orders over 100 reams, we offer competitive wholesale pricing. Let me get you a custom quote. Could you share your estimated monthly paper needs?'] }
|
||||
]
|
||||
)
|
||||
|
||||
Macro.create!(
|
||||
account: account,
|
||||
name: 'Handle Urgent Order',
|
||||
visibility: :global,
|
||||
created_by: user,
|
||||
actions: [
|
||||
{ 'action_name' => 'add_label', 'action_params' => [urgent_label.title] },
|
||||
{ 'action_name' => 'change_priority', 'action_params' => ['urgent'] },
|
||||
{ 'action_name' => 'assign_team', 'action_params' => [operations_team.id] },
|
||||
{ 'action_name' => 'send_message',
|
||||
'action_params' => ['I see you need this urgently. We offer same-day delivery for orders placed before noon. Let me check our inventory and get this expedited for you.'] }
|
||||
]
|
||||
)
|
||||
|
||||
Macro.create!(
|
||||
account: account,
|
||||
name: 'Handle Custom Printing Request',
|
||||
visibility: :global,
|
||||
created_by: user,
|
||||
actions: [
|
||||
{ 'action_name' => 'add_label', 'action_params' => [custom_print_label.title] },
|
||||
{ 'action_name' => 'assign_team', 'action_params' => [support_team.id] },
|
||||
{ 'action_name' => 'send_message',
|
||||
'action_params' => ['We provide custom letterhead printing services with a minimum order of 5 reams. Typical turnaround is 5-7 business days. Would you like to discuss your design requirements?'] }
|
||||
]
|
||||
)
|
||||
|
||||
Macro.create!(
|
||||
account: account,
|
||||
name: 'Route to Sales Team',
|
||||
visibility: :global,
|
||||
created_by: user,
|
||||
actions: [
|
||||
{ 'action_name' => 'add_label', 'action_params' => [pricing_label.title] },
|
||||
{ 'action_name' => 'assign_team', 'action_params' => [sales_team.id] },
|
||||
{ 'action_name' => 'send_message',
|
||||
'action_params' => ['I\'d be happy to provide pricing information. To give you the most accurate quote, could you let me know: 1) Paper type/weight you need, 2) Quantity required, 3) Delivery location?'] }
|
||||
]
|
||||
)
|
||||
|
||||
Macro.create!(
|
||||
account: account,
|
||||
name: 'Close with Satisfaction Check',
|
||||
visibility: :global,
|
||||
created_by: user,
|
||||
actions: [
|
||||
{ 'action_name' => 'send_message',
|
||||
'action_params' => ['Thank you for choosing Paperlayer! Is there anything else I can help you with today?'] },
|
||||
{ 'action_name' => 'resolve_conversation' }
|
||||
]
|
||||
)
|
||||
|
||||
Macro.create!(
|
||||
account: account,
|
||||
name: 'Escalate to Operations',
|
||||
visibility: :global,
|
||||
created_by: user,
|
||||
actions: [
|
||||
{ 'action_name' => 'add_label', 'action_params' => [delivery_label.title] },
|
||||
{ 'action_name' => 'assign_team', 'action_params' => [operations_team.id] },
|
||||
{ 'action_name' => 'change_priority', 'action_params' => ['high'] }
|
||||
]
|
||||
)
|
||||
|
||||
# ===============================================
|
||||
# Create 50 Sample Conversations
|
||||
# ===============================================
|
||||
|
||||
agents = [sales_lead, sales_agent_1, sales_agent_2, support_lead, support_agent_1, support_agent_2, operations_lead, operations_agent]
|
||||
|
||||
# Customer names for variety
|
||||
customer_names = [
|
||||
'Michael Stevens', 'Sarah Parker', 'John Martinez', 'Emily White', 'David Brown',
|
||||
'Jennifer Taylor', 'Robert Wilson', 'Lisa Anderson', 'James Thomas', 'Mary Jackson',
|
||||
'Christopher Lee', 'Patricia Harris', 'Daniel Clark', 'Linda Lewis', 'Matthew Walker',
|
||||
'Barbara Hall', 'Joseph Allen', 'Nancy Young', 'Ryan King', 'Karen Wright',
|
||||
'Kevin Lopez', 'Betty Hill', 'Brian Scott', 'Sandra Green', 'George Adams',
|
||||
'Jessica Baker', 'Edward Nelson', 'Margaret Carter', 'Steven Mitchell', 'Helen Roberts',
|
||||
'Andrew Turner', 'Dorothy Phillips', 'Mark Campbell', 'Carol Parker', 'Paul Evans',
|
||||
'Michelle Edwards', 'Donald Collins', 'Ashley Stewart', 'Kenneth Morris', 'Kimberly Rogers',
|
||||
'Joshua Reed', 'Amanda Cook', 'Jason Morgan', 'Melissa Bell', 'Justin Murphy',
|
||||
'Stephanie Bailey', 'Brandon Rivera', 'Rebecca Cooper', 'Eric Richardson', 'Laura Cox'
|
||||
]
|
||||
|
||||
companies = [
|
||||
'Tech Innovations Inc', 'Global Enterprises', 'Summit Corporation', 'Bright Future LLC',
|
||||
'Metro Solutions', 'Pacific Group', 'Alliance Partners', 'Premier Services',
|
||||
'Apex Industries', 'Horizon Technologies', 'Fusion Consulting', 'Vista Corp',
|
||||
'Pinnacle Systems', 'Catalyst Ventures', 'Quantum Labs', 'Nexus Holdings',
|
||||
'Zenith Partners', 'Atlas Group', 'Infinity Solutions', 'Eclipse Enterprises'
|
||||
]
|
||||
|
||||
conversation_templates = [
|
||||
{
|
||||
labels: [bulk_order_label.title, pricing_label.title],
|
||||
team: sales_team,
|
||||
priority: nil,
|
||||
status: :open,
|
||||
messages: [
|
||||
{ role: :incoming, content: 'Hi, I need a quote for 200 cases of copy paper for our office. What kind of pricing can you offer?' },
|
||||
{ role: :outgoing,
|
||||
content: 'Great! For 200 cases, you qualify for our 15% volume discount. That brings the price down to $38.25 per case. Would you like me to prepare a formal quote?' },
|
||||
{ role: :incoming, content: 'Yes please. How soon can you deliver?' }
|
||||
]
|
||||
},
|
||||
{
|
||||
labels: [custom_print_label.title],
|
||||
team: support_team,
|
||||
priority: nil,
|
||||
status: :pending,
|
||||
messages: [
|
||||
{ role: :incoming, content: 'Do you offer custom letterhead printing? We need about 10 reams with our logo.' },
|
||||
{ role: :outgoing,
|
||||
content: 'Absolutely! We can do custom letterhead with a minimum order of 5 reams. For 10 reams on 28lb premium paper, the cost would be around $200. Turnaround is 5-7 business days. Do you have your design file ready?' }
|
||||
]
|
||||
},
|
||||
{
|
||||
labels: [urgent_label.title, delivery_label.title],
|
||||
team: operations_team,
|
||||
priority: :urgent,
|
||||
status: :open,
|
||||
messages: [
|
||||
{ role: :incoming, content: 'Emergency! We need 50 reams delivered today. Is that possible?' },
|
||||
{ role: :outgoing, content: 'Yes, we can do same-day delivery within 50 miles if you order before noon. What\'s your location?' },
|
||||
{ role: :incoming, content: 'We\'re in downtown. How much for rush delivery?' }
|
||||
]
|
||||
},
|
||||
{
|
||||
labels: [product_label.title],
|
||||
team: support_team,
|
||||
priority: nil,
|
||||
status: :resolved,
|
||||
messages: [
|
||||
{ role: :incoming, content: 'What\'s the difference between 20lb and 24lb paper?' },
|
||||
{ role: :outgoing,
|
||||
content: '20lb is our standard weight, great for everyday printing. 24lb is heavier, more professional feel, and less see-through. Perfect for presentations and client-facing documents.' },
|
||||
{ role: :incoming, content: 'Perfect, I\'ll go with 24lb. Thanks!' }
|
||||
]
|
||||
},
|
||||
{
|
||||
labels: [pricing_label.title],
|
||||
team: sales_team,
|
||||
priority: nil,
|
||||
status: :open,
|
||||
messages: [
|
||||
{ role: :incoming, content: 'Can you send me your current price list for all paper weights?' },
|
||||
{ role: :outgoing,
|
||||
content: 'Of course! I\'ll email you our comprehensive price list. Are you interested in standard sizes or do you need custom dimensions as well?' }
|
||||
]
|
||||
},
|
||||
{
|
||||
labels: [delivery_label.title],
|
||||
team: operations_team,
|
||||
priority: :high,
|
||||
status: :pending,
|
||||
messages: [
|
||||
{ role: :incoming, content: 'My order #12345 hasn\'t arrived yet. It was supposed to be here yesterday.' },
|
||||
{ role: :outgoing, content: 'I apologize for the delay. Let me track that order for you right away.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
labels: [bulk_order_label.title],
|
||||
team: sales_team,
|
||||
priority: nil,
|
||||
status: :resolved,
|
||||
messages: [
|
||||
{ role: :incoming, content: 'We\'re opening 5 new offices and need to set up a recurring order. Can you help?' },
|
||||
{ role: :outgoing,
|
||||
content: 'Excellent! We have a bulk program perfect for multi-location businesses. With recurring orders over 250 cases, you get 20% off plus a dedicated account manager. When would you like to discuss the details?' },
|
||||
{ role: :incoming, content: 'That sounds great! Tomorrow at 2 PM works for me.' },
|
||||
{ role: :outgoing, content: 'Perfect, I\'ve scheduled a call for tomorrow at 2 PM. I\'ll send you a calendar invite shortly.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
labels: [product_label.title, pricing_label.title],
|
||||
team: sales_team,
|
||||
priority: nil,
|
||||
status: :open,
|
||||
messages: [
|
||||
{ role: :incoming, content: 'Do you have recycled paper options? We\'re trying to go green.' },
|
||||
{ role: :outgoing,
|
||||
content: 'Yes! Our Eco-Friendly line is 30% post-consumer recycled content and FSC certified. It\'s $48 per case, just $3 more than standard paper.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
labels: [custom_print_label.title, urgent_label.title],
|
||||
team: support_team,
|
||||
priority: :urgent,
|
||||
status: :open,
|
||||
messages: [
|
||||
{ role: :incoming, content: 'We have a trade show next week and need 1000 business cards ASAP. Can you rush this?' },
|
||||
{ role: :outgoing,
|
||||
content: 'For rush orders, we can turn it around in 2-3 business days for an additional $50 rush fee. Would that work for your timeline?' }
|
||||
]
|
||||
},
|
||||
{
|
||||
labels: [delivery_label.title],
|
||||
team: operations_team,
|
||||
priority: nil,
|
||||
status: :resolved,
|
||||
messages: [
|
||||
{ role: :incoming, content: 'What are your delivery hours? We have limited receiving times.' },
|
||||
{ role: :outgoing,
|
||||
content: 'Our standard delivery window is 9 AM - 5 PM. We can accommodate specific time windows with advance notice. What times work best for you?' },
|
||||
{ role: :incoming, content: '10 AM - 2 PM would be ideal.' },
|
||||
{ role: :outgoing, content: 'Noted! I\'ve added that to your account preferences.' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
puts ' Creating 50 diverse conversations...'
|
||||
|
||||
50.times do |i|
|
||||
# Create contact
|
||||
customer_name = customer_names[i % customer_names.length]
|
||||
company = companies[i % companies.length]
|
||||
|
||||
contact_inbox = ContactInboxWithContactBuilder.new(
|
||||
source_id: agents.sample.id,
|
||||
inbox: [inbox, sales_inbox, support_inbox].sample,
|
||||
hmac_verified: true,
|
||||
contact_attributes: {
|
||||
name: customer_name,
|
||||
email: "#{customer_name.downcase.tr(' ', '.')}@#{company.downcase.delete(' ')}.com",
|
||||
phone_number: "+1555#{rand(1_000_000..9_999_999)}",
|
||||
additional_attributes: {
|
||||
company_name: company,
|
||||
city: ['New York', 'Los Angeles', 'Chicago', 'Houston', 'Phoenix', 'Philadelphia'].sample
|
||||
}
|
||||
}
|
||||
).perform
|
||||
|
||||
# Pick a template or create variation
|
||||
template = conversation_templates[i % conversation_templates.length]
|
||||
|
||||
# Assign to agent from appropriate team
|
||||
team_members = TeamMember.where(team: template[:team]).pluck(:user_id)
|
||||
assigned_agent = User.find(team_members.sample)
|
||||
|
||||
# Create conversation
|
||||
conv = Conversation.create!(
|
||||
account: account,
|
||||
inbox: contact_inbox.inbox,
|
||||
status: template[:status],
|
||||
assignee: assigned_agent,
|
||||
contact: contact_inbox.contact,
|
||||
contact_inbox: contact_inbox,
|
||||
priority: template[:priority],
|
||||
additional_attributes: {
|
||||
initiated_at: {
|
||||
timestamp: rand(30).days.ago.to_i
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Add labels
|
||||
conv.label_list.add(*template[:labels])
|
||||
conv.save!
|
||||
|
||||
# Create messages
|
||||
template[:messages].each do |msg_template|
|
||||
sender = msg_template[:role] == :incoming ? contact_inbox.contact : assigned_agent
|
||||
Message.create!(
|
||||
content: msg_template[:content],
|
||||
account: account,
|
||||
inbox: contact_inbox.inbox,
|
||||
conversation: conv,
|
||||
sender: sender,
|
||||
message_type: msg_template[:role]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Create one special conversation with interactive messages
|
||||
special_contact = ContactInboxWithContactBuilder.new(
|
||||
source_id: sales_lead.id,
|
||||
inbox: inbox,
|
||||
hmac_verified: true,
|
||||
contact_attributes: { name: 'jane', email: 'jane@example.com', phone_number: '+2320000' }
|
||||
contact_attributes: {
|
||||
name: 'Demo Customer',
|
||||
email: 'demo@example.com',
|
||||
phone_number: '+15551234567'
|
||||
}
|
||||
).perform
|
||||
|
||||
conversation = Conversation.create!(
|
||||
demo_conversation = Conversation.create!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
status: :open,
|
||||
assignee: user,
|
||||
contact: contact_inbox.contact,
|
||||
contact_inbox: contact_inbox,
|
||||
assignee: sales_lead,
|
||||
contact: special_contact.contact,
|
||||
contact_inbox: special_contact,
|
||||
additional_attributes: {}
|
||||
)
|
||||
demo_conversation.label_list.add(bulk_order_label.title)
|
||||
demo_conversation.save!
|
||||
|
||||
# sample email collect
|
||||
Seeders::MessageSeeder.create_sample_email_collect_message conversation
|
||||
Message.create!(content: 'Hi! I\'m interested in learning more about your products.', account: account, inbox: inbox,
|
||||
conversation: demo_conversation, sender: special_contact.contact, message_type: :incoming)
|
||||
|
||||
Message.create!(content: 'Hello', account: account, inbox: inbox, conversation: conversation, sender: contact_inbox.contact,
|
||||
message_type: :incoming)
|
||||
# Add interactive message samples
|
||||
Seeders::MessageSeeder.create_sample_cards_message demo_conversation
|
||||
Seeders::MessageSeeder.create_sample_input_select_message demo_conversation
|
||||
Seeders::MessageSeeder.create_sample_form_message demo_conversation
|
||||
Seeders::MessageSeeder.create_sample_articles_message demo_conversation
|
||||
Seeders::MessageSeeder.create_sample_email_collect_message demo_conversation
|
||||
Seeders::MessageSeeder.create_sample_csat_collect_message demo_conversation
|
||||
|
||||
# sample location message
|
||||
#
|
||||
location_message = Message.new(content: 'location', account: account, inbox: inbox, sender: contact_inbox.contact, conversation: conversation,
|
||||
message_type: :incoming)
|
||||
location_message.attachments.new(
|
||||
account_id: account.id,
|
||||
file_type: 'location',
|
||||
coordinates_lat: 37.7893768,
|
||||
coordinates_long: -122.3895553,
|
||||
fallback_title: 'Bay Bridge, San Francisco, CA, USA'
|
||||
puts ' ✓ Created 50+ conversations with diverse scenarios'
|
||||
|
||||
# ===============================================
|
||||
# Help Center Portal Setup
|
||||
# ===============================================
|
||||
portal = Portal.create!(
|
||||
account: account,
|
||||
name: 'Paperlayer Help Center',
|
||||
slug: 'dunder-mifflin-help',
|
||||
page_title: 'Paperlayer Paper Company - Help & Support',
|
||||
header_text: 'How can we help you with your paper needs?',
|
||||
homepage_link: 'https://paperlayer.com',
|
||||
color: '#0066CC',
|
||||
config: {
|
||||
'allowed_locales' => ['en'],
|
||||
'default_locale' => 'en'
|
||||
}
|
||||
)
|
||||
location_message.save!
|
||||
|
||||
# sample card
|
||||
Seeders::MessageSeeder.create_sample_cards_message conversation
|
||||
# input select
|
||||
Seeders::MessageSeeder.create_sample_input_select_message conversation
|
||||
# form
|
||||
Seeders::MessageSeeder.create_sample_form_message conversation
|
||||
# articles
|
||||
Seeders::MessageSeeder.create_sample_articles_message conversation
|
||||
# csat
|
||||
Seeders::MessageSeeder.create_sample_csat_collect_message conversation
|
||||
# Category: Getting Started
|
||||
getting_started_category = Category.create!(
|
||||
account: account,
|
||||
portal: portal,
|
||||
name: 'Getting Started',
|
||||
slug: 'getting-started',
|
||||
description: 'Learn the basics of ordering from Paperlayer',
|
||||
locale: 'en',
|
||||
position: 1,
|
||||
icon: 'book-open'
|
||||
)
|
||||
|
||||
CannedResponse.create!(account: account, short_code: 'start', content: 'Hello welcome to chatwoot.')
|
||||
Article.create!(
|
||||
account: account,
|
||||
portal: portal,
|
||||
category: getting_started_category,
|
||||
author: user,
|
||||
title: 'How to Place Your First Order',
|
||||
description: 'Step-by-step guide to placing your first paper order with Paperlayer',
|
||||
content: "# How to Place Your First Order\n\nWelcome to Paperlayer! We're excited to help you with your paper needs.\n\n## Step 1: Browse Our Products\nVisit our product catalog to explore our wide range of paper products:\n- Copy Paper (20lb, 24lb, 28lb, 32lb)\n- Cardstock (65lb, 80lb, 110lb)\n- Specialty Papers (colored, glossy, recycled)\n\n## Step 2: Request a Quote\nContact our sales team via:\n- Live chat on our website\n- Email: sales@paperlayer.com\n- Phone: 1-800-PAPER-01\n\n## Step 3: Review and Approve\nOur team will send you a detailed quote within 24 hours.\n\n## Step 4: Place Your Order\nOnce approved, we'll process your order and provide tracking information.\n\n## Step 5: Delivery\nStandard delivery: 2-3 business days\nSame-day delivery: Available for local orders placed before noon",
|
||||
status: :published,
|
||||
locale: 'en'
|
||||
)
|
||||
|
||||
Article.create!(
|
||||
account: account,
|
||||
portal: portal,
|
||||
category: getting_started_category,
|
||||
author: user,
|
||||
title: 'Understanding Paper Weights',
|
||||
description: 'Learn about different paper weights and their best uses',
|
||||
content: "# Understanding Paper Weights\n\nPaper weight can be confusing. Let us help you choose the right weight for your needs.\n\n## Copy Paper Weights\n\n### 20lb (75 gsm) - Standard\n- Most common weight for everyday printing\n- Great for internal documents, drafts\n- Economical choice for high-volume printing\n\n### 24lb (90 gsm) - Premium\n- Heavier feel, more professional\n- Ideal for presentations, resumes\n- Less see-through than 20lb\n\n### 28lb (105 gsm) - Super Premium\n- Excellent for important documents\n- Professional brochures\n- High-quality letterhead\n\n### 32lb (120 gsm) - Ultra Premium\n- Luxury feel and appearance\n- Executive communications\n- Certificates and awards\n\n## Cardstock Weights\n\n### 65lb (176 gsm) - Light Cardstock\n- Greeting cards, postcards\n- Thin presentations\n\n### 80lb (216 gsm) - Medium Cardstock\n- Business cards\n- Invitations\n\n### 110lb (298 gsm) - Heavy Cardstock\n- Premium business cards\n- High-end invitations\n- Display materials\n\n## Need Help?\nContact our team at sales@paperlayer.com or call 1-800-PAPER-01",
|
||||
status: :published,
|
||||
locale: 'en'
|
||||
)
|
||||
|
||||
# Category: Products & Pricing
|
||||
products_category = Category.create!(
|
||||
account: account,
|
||||
portal: portal,
|
||||
name: 'Products & Pricing',
|
||||
slug: 'products-pricing',
|
||||
description: 'Information about our paper products and pricing',
|
||||
locale: 'en',
|
||||
position: 2,
|
||||
icon: 'tag'
|
||||
)
|
||||
|
||||
Article.create!(
|
||||
account: account,
|
||||
portal: portal,
|
||||
category: products_category,
|
||||
author: user,
|
||||
title: 'Copy Paper Product Line',
|
||||
description: 'Complete guide to our copy paper offerings',
|
||||
content: "# Copy Paper Product Line\n\nPaperlayer offers the finest copy paper in the Northeast.\n\n## Standard Copy Paper\n**Premium White - 20lb**\n- Price: $45 per case (10 reams)\n- Brightness: 92\n- Size: 8.5\" x 11\"\n- Sheets per ream: 500\n\n**Premium White - 24lb**\n- Price: $52 per case (10 reams)\n- Brightness: 94\n- Size: 8.5\" x 11\"\n- Sheets per ream: 500\n\n## Premium Copy Paper\n**Ultra White - 28lb**\n- Price: $65 per case (10 reams)\n- Brightness: 96\n- Perfect for presentations\n\n**Executive White - 32lb**\n- Price: $78 per case (10 reams)\n- Brightness: 98\n- Luxury feel and appearance\n\n## Recycled Paper\n**Eco-Friendly - 20lb**\n- Price: $48 per case (10 reams)\n- 30% post-consumer content\n- FSC certified\n\n## Volume Discounts\n- 50-99 cases: 10% off\n- 100-249 cases: 15% off\n- 250+ cases: 20% off\n\nContact us for custom quotes!",
|
||||
status: :published,
|
||||
locale: 'en'
|
||||
)
|
||||
|
||||
Article.create!(
|
||||
account: account,
|
||||
portal: portal,
|
||||
category: products_category,
|
||||
author: user,
|
||||
title: 'Bulk Order Discounts',
|
||||
description: 'Learn about our volume pricing tiers and save on large orders',
|
||||
content: "# Bulk Order Discounts\n\nThe more you order, the more you save with Paperlayer!\n\n## Discount Tiers\n\n### Tier 1: 50-99 Cases\n- **10% discount**\n- Minimum order: 50 cases\n- Perfect for medium-sized offices\n\n### Tier 2: 100-249 Cases\n- **15% discount**\n- Quarterly supply option\n- Dedicated account manager\n\n### Tier 3: 250+ Cases\n- **20% discount**\n- Annual contract pricing\n- Priority customer service\n- Free delivery\n- Flexible payment terms\n\n## Example Savings\n\n**Standard scenario: 100 cases of Premium White 20lb**\n- Regular price: $4,500\n- With 15% discount: $3,825\n- **You save: $675!**\n\n## Additional Benefits\n- No order minimums after initial bulk purchase\n- Locked-in pricing for contract duration\n- Scheduled deliveries\n- Custom invoicing options\n\n## Get Started\nContact our bulk sales team:\n- Email: bulk@paperlayer.com\n- Phone: 1-800-BULK-PAPER\n- Live chat: Available 9 AM - 6 PM EST",
|
||||
status: :published,
|
||||
locale: 'en'
|
||||
)
|
||||
|
||||
# Category: Shipping & Delivery
|
||||
shipping_category = Category.create!(
|
||||
account: account,
|
||||
portal: portal,
|
||||
name: 'Shipping & Delivery',
|
||||
slug: 'shipping-delivery',
|
||||
description: 'Delivery options, shipping costs, and tracking information',
|
||||
locale: 'en',
|
||||
position: 3,
|
||||
icon: 'truck'
|
||||
)
|
||||
|
||||
Article.create!(
|
||||
account: account,
|
||||
portal: portal,
|
||||
category: shipping_category,
|
||||
author: user,
|
||||
title: 'Delivery Options & Times',
|
||||
description: 'Choose the delivery option that works best for you',
|
||||
content: "# Delivery Options & Times\n\n## Standard Delivery (FREE)\n- **Timeframe:** 2-3 business days\n- **Available:** All orders over $100\n- **Coverage:** Within 200 miles of Scranton, PA\n\n## Express Delivery\n- **Timeframe:** Next business day\n- **Cost:** $25 flat fee\n- **Coverage:** Within 100 miles\n- **Cutoff:** Order by 3 PM\n\n## Same-Day Delivery\n- **Timeframe:** Within 6 hours\n- **Cost:** $50 flat fee\n- **Coverage:** Within 50 miles\n- **Cutoff:** Order by 12 PM noon\n\n## Freight Shipping (Bulk Orders)\n- **Timeframe:** 3-5 business days\n- **Cost:** Varies by distance and volume\n- **Available:** Orders over 100 cases\n- **Includes:** Forklift delivery available\n\n## Tracking Your Order\nAll orders include:\n- Real-time tracking number\n- Email notifications\n- Estimated delivery window\n- Delivery signature confirmation\n\n## Delivery Notes\n- Residential deliveries available\n- Loading dock delivery preferred for bulk\n- Inside delivery available (additional fee)\n- Weather delays may occur\n\nQuestions? Contact: logistics@paperlayer.com",
|
||||
status: :published,
|
||||
locale: 'en'
|
||||
)
|
||||
|
||||
# Category: Custom Services
|
||||
custom_category = Category.create!(
|
||||
account: account,
|
||||
portal: portal,
|
||||
name: 'Custom Services',
|
||||
slug: 'custom-services',
|
||||
description: 'Custom printing, letterhead, and specialty services',
|
||||
locale: 'en',
|
||||
position: 4,
|
||||
icon: 'printer'
|
||||
)
|
||||
|
||||
Article.create!(
|
||||
account: account,
|
||||
portal: portal,
|
||||
category: custom_category,
|
||||
author: user,
|
||||
title: 'Custom Letterhead & Business Stationery',
|
||||
description: 'Professional custom printing services for your business',
|
||||
content: "# Custom Letterhead & Business Stationery\n\nMake a lasting impression with custom-printed materials from Paperlayer.\n\n## Letterhead Services\n\n### Standard Letterhead\n- **Minimum order:** 5 reams (2,500 sheets)\n- **Turnaround:** 5-7 business days\n- **Paper weight:** 24lb or 28lb\n- **Colors:** Up to 4-color process\n- **Starting at:** $15 per ream\n\n### Premium Letterhead\n- **Minimum order:** 5 reams\n- **Turnaround:** 7-10 business days\n- **Paper weight:** 32lb premium stock\n- **Options:** Embossing, foil stamping\n- **Starting at:** $25 per ream\n\n## Business Cards\n- Standard: 14pt cardstock\n- Premium: 16pt with UV coating\n- Minimum: 500 cards\n- Turnaround: 3-5 business days\n\n## Envelopes\n- Custom printed envelopes\n- Standard #10 size\n- Match your letterhead design\n- Minimum: 500 envelopes\n\n## Design Services\n- Professional design assistance: $150\n- Logo digitization: $75\n- Proof revisions: Included (up to 3)\n\n## The Process\n\n1. **Submit your design** - Email to custom@paperlayer.com\n2. **Review proof** - We'll send a digital proof within 2 business days\n3. **Approve** - Make any needed revisions\n4. **Production** - We print your order\n5. **Delivery** - Ships via your chosen method\n\n## File Requirements\n- Format: PDF, AI, or EPS\n- Resolution: 300 DPI minimum\n- Color mode: CMYK\n- Bleed: 0.125\" on all sides\n\nNeed help with design? We can assist!\nEmail: custom@paperlayer.com\nPhone: 1-800-CUSTOM-PAPER",
|
||||
status: :published,
|
||||
locale: 'en'
|
||||
)
|
||||
|
||||
# ===============================================
|
||||
# Captain Assistant Setup (Enterprise Feature)
|
||||
# ===============================================
|
||||
if defined?(Captain::Assistant)
|
||||
captain_assistant = Captain::Assistant.create!(
|
||||
account: account,
|
||||
name: 'Paper Expert',
|
||||
description: 'AI assistant specialized in helping customers with all their paper product needs, from product selection to bulk orders.',
|
||||
config: {
|
||||
temperature: 0.7,
|
||||
feature_faq: true,
|
||||
feature_memory: true,
|
||||
product_name: 'Paperlayer Paper Products'
|
||||
},
|
||||
response_guidelines: [
|
||||
'Always be friendly and professional',
|
||||
'Use paper industry terminology when appropriate',
|
||||
'Provide specific product recommendations based on customer needs',
|
||||
'Mention bulk discounts when discussing large orders',
|
||||
'Offer to connect customers with sales team for custom quotes'
|
||||
],
|
||||
guardrails: [
|
||||
'Do not discuss competitor products',
|
||||
'Do not make promises about delivery times without confirming inventory',
|
||||
'Always verify bulk pricing with sales team for orders over 250 cases'
|
||||
]
|
||||
)
|
||||
|
||||
# Connect assistant to inbox
|
||||
CaptainInbox.create!(
|
||||
captain_assistant: captain_assistant,
|
||||
inbox: inbox
|
||||
)
|
||||
|
||||
# Scenario 1: Product Recommendations
|
||||
Captain::Scenario.create!(
|
||||
account: account,
|
||||
assistant: captain_assistant,
|
||||
title: 'Product Recommendations',
|
||||
description: 'Help customers choose the right paper products for their needs',
|
||||
instruction: "When a customer asks about which paper to use, ask clarifying questions:\n1. What will they use it for? (everyday printing, presentations, letterhead, etc.)\n2. What's their volume? (help them understand bulk discounts)\n3. Do they have any special requirements? (recycled, brightness, specific sizes)\n\nThen recommend appropriate products from our catalog:\n- 20lb for everyday use\n- 24lb for professional documents\n- 28lb for presentations\n- 32lb for executive communications\n- Cardstock for business cards\n\nAlways mention relevant discounts and offer to connect them with sales for quotes.",
|
||||
enabled: true
|
||||
)
|
||||
|
||||
# Scenario 2: Bulk Orders
|
||||
Captain::Scenario.create!(
|
||||
account: account,
|
||||
assistant: captain_assistant,
|
||||
title: 'Bulk Order Processing',
|
||||
description: 'Guide customers through bulk order process and pricing',
|
||||
instruction: "For bulk order inquiries:\n1. Determine their quantity needs\n2. Explain applicable discount tiers:\n - 50-99 cases: 10% off\n - 100-249 cases: 15% off\n - 250+ cases: 20% off\n3. Calculate estimated savings\n4. Explain additional benefits (account manager, flexible terms)\n5. Offer to connect with bulk sales team for formal quote\n6. Add the 'bulk-order' label to the conversation",
|
||||
enabled: true
|
||||
)
|
||||
|
||||
# Scenario 3: Shipping Questions
|
||||
Captain::Scenario.create!(
|
||||
account: account,
|
||||
assistant: captain_assistant,
|
||||
title: 'Shipping & Delivery',
|
||||
description: 'Answer questions about delivery options and times',
|
||||
instruction: "When customers ask about shipping:\n1. Standard delivery: 2-3 business days (FREE over $100)\n2. Express: Next day ($25)\n3. Same-day: Within 6 hours, order by noon ($50)\n4. Bulk freight: 3-5 days for 100+ cases\n\nMention tracking is included with all orders.\nFor urgent needs, emphasize same-day delivery within 50 miles.",
|
||||
enabled: true
|
||||
)
|
||||
|
||||
# Document 1: FAQ - General Questions
|
||||
Captain::Document.create!(
|
||||
account: account,
|
||||
assistant: captain_assistant,
|
||||
name: 'General FAQs',
|
||||
external_link: 'https://paperlayer.com/faq/general',
|
||||
content: "# Frequently Asked Questions\n\n## What are your business hours?\nMonday-Friday: 8 AM - 6 PM EST\nSaturday: 9 AM - 2 PM EST\nSunday: Closed\n\n## Do you offer free shipping?\nYes! Free standard shipping on all orders over $100 within 200 miles of Scranton, PA.\n\n## What payment methods do you accept?\n- Credit cards (Visa, Mastercard, AmEx, Discover)\n- Purchase orders (for established accounts)\n- ACH/Wire transfer\n- Net 30 terms (for qualified businesses)\n\n## Can I return paper products?\nYes, unopened reams can be returned within 30 days for a full refund.\n\n## Do you have a showroom?\nYes! Visit our Scranton office at:\n1725 Slough Avenue\nScranton, PA 18505\n\nAppointments recommended.\n\n## What makes Paperlayer different?\n- Family-owned since 1949\n- Personal service from dedicated account managers\n- Local delivery and same-day service\n- Competitive pricing with volume discounts\n- Expert advice on paper selection",
|
||||
status: :available
|
||||
)
|
||||
|
||||
# Document 2: FAQ - Product Specifications
|
||||
Captain::Document.create!(
|
||||
account: account,
|
||||
assistant: captain_assistant,
|
||||
name: 'Product Specifications',
|
||||
external_link: 'https://paperlayer.com/faq/products',
|
||||
content: "# Product Specifications FAQs\n\n## What does paper brightness mean?\nBrightness is measured on a scale of 0-100. Higher numbers mean whiter, brighter paper.\n- 92: Standard brightness\n- 94-96: Premium brightness\n- 98+: Ultra-premium brightness\n\n## What's the difference between 20lb and 24lb paper?\nThe number refers to the weight of 500 sheets (a ream).\n- 20lb: Standard weight, economical\n- 24lb: Heavier, more professional feel\n- 28lb: Premium weight for presentations\n- 32lb: Luxury weight for executive use\n\n## Is your paper acid-free?\nYes, all our copy paper is acid-free for long-term archival quality.\n\n## Do you carry recycled paper?\nYes! Our Eco-Friendly line contains 30% post-consumer recycled content and is FSC certified.\n\n## What sizes do you offer?\n- Letter: 8.5\" x 11\" (most common)\n- Legal: 8.5\" x 14\"\n- Tabloid: 11\" x 17\"\n- Custom sizes available for bulk orders\n\n## Can I get paper in colors?\nYes! We stock:\n- Pastels: Ivory, Pink, Blue, Green, Yellow\n- Brights: Red, Orange, Purple\n- Custom colors available for bulk orders\n\n## What's the shelf life of paper?\nPaper properly stored in a cool, dry place will last indefinitely.",
|
||||
status: :available
|
||||
)
|
||||
|
||||
# Document 3: Bulk Ordering Guide
|
||||
Captain::Document.create!(
|
||||
account: account,
|
||||
assistant: captain_assistant,
|
||||
name: 'Bulk Ordering Guide',
|
||||
external_link: 'https://paperlayer.com/guides/bulk-orders',
|
||||
content: "# Bulk Ordering Guide\n\n## Benefits of Bulk Orders\n\n### Cost Savings\n- 50-99 cases: Save 10%\n- 100-249 cases: Save 15%\n- 250+ cases: Save 20%\n\n### Additional Perks\n- Dedicated account manager\n- Priority customer service\n- Flexible payment terms (Net 30/60/90)\n- Free delivery on orders over 100 cases\n- Scheduled recurring deliveries\n- Custom reporting and invoicing\n\n## How to Place a Bulk Order\n\n1. **Contact our bulk sales team**\n - Email: bulk@paperlayer.com\n - Phone: 1-800-BULK-PAPER\n - Chat with us online\n\n2. **Discuss your needs**\n - Types of paper needed\n - Estimated monthly/annual volume\n - Delivery schedule preferences\n\n3. **Receive your custom quote**\n - Within 24 hours for standard requests\n - Same day for urgent needs\n\n4. **Review and sign agreement**\n - No long-term contracts required\n - Flexible terms available\n\n5. **Start saving!**\n - Automated ordering available\n - Just-in-time delivery options\n\n## Storage Tips for Bulk Orders\n- Store in cool, dry place (60-70°F)\n- Keep away from direct sunlight\n- Maintain 40-60% humidity\n- Store flat, not on edge\n- Keep in original packaging until use\n\n## Popular Bulk Order Scenarios\n\n### Small Office (50-100 cases/year)\n- Mix of 20lb and 24lb paper\n- Quarterly deliveries\n- 10% savings tier\n\n### Medium Business (100-250 cases/year)\n- Multiple paper types\n- Monthly deliveries\n- 15% savings tier\n- Account manager included\n\n### Large Corporation (250+ cases/year)\n- Enterprise agreement\n- Custom delivery schedule\n- 20% savings tier\n- Premium support\n\n## Questions?\nContact our bulk sales specialists:\nbulk@paperlayer.com",
|
||||
status: :available
|
||||
)
|
||||
|
||||
# Document 4: Custom Printing Guide
|
||||
Captain::Document.create!(
|
||||
account: account,
|
||||
assistant: captain_assistant,
|
||||
name: 'Custom Printing Services',
|
||||
external_link: 'https://paperlayer.com/services/custom-printing',
|
||||
content: "# Custom Printing Services\n\n## What We Offer\n\n### Letterhead\n- Professional custom letterhead\n- Minimum: 5 reams (2,500 sheets)\n- Paper options: 24lb, 28lb, or 32lb\n- Full color printing\n- Turnaround: 5-10 business days\n\n### Business Cards\n- Premium cardstock\n- Standard or custom sizes\n- Options: matte, gloss, UV coating\n- Minimum: 500 cards\n- Turnaround: 3-5 business days\n\n### Envelopes\n- Custom printed envelopes\n- Match your letterhead\n- Multiple sizes available\n- Minimum: 500 envelopes\n\n## Design Services\n\nNeed help with design?\n- Professional design: $150\n- Logo digitization: $75\n- Free setup for repeat orders\n- Up to 3 proof revisions included\n\n## File Requirements\n- Preferred format: PDF, AI, EPS\n- Resolution: 300 DPI minimum\n- Color mode: CMYK (not RGB)\n- Include 0.125\" bleed\n- Embed all fonts\n\n## Approval Process\n\n1. Submit your design or request assistance\n2. Receive digital proof within 2 business days\n3. Review and approve (or request changes)\n4. Production begins upon approval\n5. Delivery within specified timeframe\n\n## Pricing Examples\n\n**Standard Letterhead (24lb)**\n- 5 reams: $75\n- 10 reams: $140\n- 25 reams: $325\n\n**Premium Letterhead (32lb)**\n- 5 reams: $125\n- 10 reams: $230\n- 25 reams: $525\n\n**Business Cards**\n- 500 cards: $75\n- 1,000 cards: $125\n- 2,500 cards: $275\n\n## Get Started\n\nEmail your project details to:\ncustom@paperlayer.com\n\nOr call: 1-800-CUSTOM-PAPER",
|
||||
status: :available
|
||||
)
|
||||
|
||||
puts ' ✓ Captain Assistant created with documents and scenarios'
|
||||
else
|
||||
puts ' ⚠ Captain Assistant is an Enterprise feature - skipping'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,9 @@ require 'administrate/field/base'
|
||||
|
||||
class AccountLimitsField < Administrate::Field::Base
|
||||
def to_s
|
||||
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json
|
||||
defaults = { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil, emails: nil }
|
||||
overrides = (data.presence || {}).to_h.symbolize_keys.compact
|
||||
|
||||
defaults.merge(overrides).to_json
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module Enterprise::Account::PlanUsageAndLimits
|
||||
module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleLength
|
||||
CAPTAIN_RESPONSES = 'captain_responses'.freeze
|
||||
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
|
||||
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
|
||||
@@ -32,6 +32,10 @@ module Enterprise::Account::PlanUsageAndLimits
|
||||
save
|
||||
end
|
||||
|
||||
def email_rate_limit
|
||||
account_limit || plan_email_limit || global_limit || default_limit
|
||||
end
|
||||
|
||||
def subscribed_features
|
||||
plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value
|
||||
return [] if plan_features.blank?
|
||||
@@ -68,6 +72,16 @@ module Enterprise::Account::PlanUsageAndLimits
|
||||
}
|
||||
end
|
||||
|
||||
def plan_email_limit
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_EMAILS_PLAN_LIMITS')&.value
|
||||
return nil if config.blank? || plan_name.blank?
|
||||
|
||||
parsed = config.is_a?(String) ? JSON.parse(config) : config
|
||||
parsed[plan_name.downcase]&.to_i
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
def default_captain_limits
|
||||
max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access
|
||||
zero_limits = { documents: 0, responses: 0 }.with_indifferent_access
|
||||
@@ -119,7 +133,8 @@ module Enterprise::Account::PlanUsageAndLimits
|
||||
'inboxes' => { 'type': 'number' },
|
||||
'agents' => { 'type': 'number' },
|
||||
'captain_responses' => { 'type': 'number' },
|
||||
'captain_documents' => { 'type': 'number' }
|
||||
'captain_documents' => { 'type': 'number' },
|
||||
'emails' => { 'type': 'number' }
|
||||
},
|
||||
'required' => [],
|
||||
'additionalProperties' => false
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Captain::Tools::BaseTool < RubyLLM::Tool
|
||||
prepend Captain::Tools::Instrumentation
|
||||
|
||||
attr_accessor :assistant
|
||||
|
||||
def initialize(assistant, user: nil)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
module Captain::Tools::Instrumentation
|
||||
extend ActiveSupport::Concern
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
def execute(**args)
|
||||
instrument_tool_call(name, args) do
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool
|
||||
prepend Captain::Tools::Instrumentation
|
||||
|
||||
description 'Search and retrieve documentation/FAQs from knowledge base'
|
||||
|
||||
param :query, desc: 'Search Query', required: true
|
||||
|
||||
def initialize(account:, assistant: nil)
|
||||
@account = account
|
||||
@assistant = assistant
|
||||
super()
|
||||
end
|
||||
|
||||
def name
|
||||
'search_documentation'
|
||||
end
|
||||
|
||||
def execute(query:)
|
||||
Rails.logger.info { "#{self.class.name}: #{query}" }
|
||||
|
||||
responses = search_responses(query)
|
||||
return 'No FAQs found for the given query' if responses.empty?
|
||||
|
||||
responses.map { |response| format_response(response) }.join
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_responses(query)
|
||||
if @assistant.present?
|
||||
@assistant.responses.approved.search(query, account_id: @account.id)
|
||||
else
|
||||
@account.captain_assistant_responses.approved.search(query, account_id: @account.id)
|
||||
end
|
||||
end
|
||||
|
||||
def format_response(response)
|
||||
result = "\nQuestion: #{response.question}\nAnswer: #{response.answer}\n"
|
||||
result += "Source: #{response.documentable.external_link}\n" if response.documentable.present? && response.documentable.try(:external_link)
|
||||
result
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
module Enterprise::Captain::ReplySuggestionService
|
||||
def make_api_call(model:, messages:, tools: [])
|
||||
return super unless use_search_tool?
|
||||
|
||||
super(model: model, messages: messages, tools: [build_search_tool])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def use_search_tool?
|
||||
ChatwootApp.chatwoot_cloud? || ChatwootApp.self_hosted_enterprise?
|
||||
end
|
||||
|
||||
def prompt_variables
|
||||
return super unless use_search_tool?
|
||||
|
||||
super.merge('has_search_tool' => true)
|
||||
end
|
||||
|
||||
def build_search_tool
|
||||
assistant = conversation&.inbox&.captain_assistant
|
||||
Captain::Tools::SearchReplyDocumentationService.new(account: account, assistant: assistant)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
class Captain::BaseTaskService
|
||||
include Integrations::LlmInstrumentation
|
||||
include Captain::ToolInstrumentation
|
||||
|
||||
# gpt-4o-mini supports 128,000 tokens
|
||||
# 1 token is approx 4 characters
|
||||
@@ -35,44 +36,52 @@ class Captain::BaseTaskService
|
||||
"#{endpoint}/v1"
|
||||
end
|
||||
|
||||
def make_api_call(model:, messages:)
|
||||
def make_api_call(model:, messages:, tools: [])
|
||||
# Community edition prerequisite checks
|
||||
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
|
||||
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
|
||||
return { error: I18n.t('captain.api_key_missing'), error_code: 401 } unless api_key_configured?
|
||||
|
||||
instrumentation_params = build_instrumentation_params(model, messages)
|
||||
instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call
|
||||
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
execute_ruby_llm_request(model: model, messages: messages)
|
||||
response = send(instrumentation_method, instrumentation_params) do
|
||||
execute_ruby_llm_request(model: model, messages: messages, tools: tools)
|
||||
end
|
||||
|
||||
# Build follow-up context for client-side refinement, when applicable
|
||||
if build_follow_up_context? && response[:message].present?
|
||||
response.merge(follow_up_context: build_follow_up_context(messages, response))
|
||||
else
|
||||
response
|
||||
end
|
||||
return response unless build_follow_up_context? && response[:message].present?
|
||||
|
||||
response.merge(follow_up_context: build_follow_up_context(messages, response))
|
||||
end
|
||||
|
||||
def execute_ruby_llm_request(model:, messages:)
|
||||
def execute_ruby_llm_request(model:, messages:, tools: [])
|
||||
Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
|
||||
chat = context.chat(model: model)
|
||||
system_msg = messages.find { |m| m[:role] == 'system' }
|
||||
chat.with_instructions(system_msg[:content]) if system_msg
|
||||
chat = build_chat(context, model: model, messages: messages, tools: tools)
|
||||
|
||||
conversation_messages = messages.reject { |m| m[:role] == 'system' }
|
||||
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
|
||||
|
||||
add_messages_if_needed(chat, conversation_messages)
|
||||
response = chat.ask(conversation_messages.last[:content])
|
||||
build_ruby_llm_response(response, messages)
|
||||
build_ruby_llm_response(chat.ask(conversation_messages.last[:content]), messages)
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
{ error: e.message, request_messages: messages }
|
||||
end
|
||||
|
||||
def build_chat(context, model:, messages:, tools: [])
|
||||
chat = context.chat(model: model)
|
||||
system_msg = messages.find { |m| m[:role] == 'system' }
|
||||
chat.with_instructions(system_msg[:content]) if system_msg
|
||||
|
||||
if tools.any?
|
||||
tools.each { |tool| chat = chat.with_tool(tool) }
|
||||
chat.on_end_message { |message| record_generation(chat, message, model) }
|
||||
end
|
||||
|
||||
chat
|
||||
end
|
||||
|
||||
def add_messages_if_needed(chat, conversation_messages)
|
||||
return if conversation_messages.length == 1
|
||||
|
||||
@@ -177,5 +186,4 @@ class Captain::BaseTaskService
|
||||
user_msg ? user_msg[:content] : nil
|
||||
end
|
||||
end
|
||||
|
||||
Captain::BaseTaskService.prepend_mod_with('Captain::BaseTaskService')
|
||||
|
||||
@@ -38,3 +38,5 @@ class Captain::ReplySuggestionService < Captain::BaseTaskService
|
||||
'reply_suggestion'
|
||||
end
|
||||
end
|
||||
|
||||
Captain::ReplySuggestionService.prepend_mod_with('Captain::ReplySuggestionService')
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
module Captain::ToolInstrumentation
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
# Custom instrumentation for tool flows - outputs just the message (not full hash)
|
||||
def instrument_tool_session(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
response = nil
|
||||
executed = false
|
||||
tracer.in_span(params[:span_name]) do |span|
|
||||
span.set_attribute('langfuse.user.id', params[:account_id].to_s) if params[:account_id]
|
||||
span.set_attribute('langfuse.tags', [params[:feature_name]].to_json)
|
||||
span.set_attribute('langfuse.observation.input', params[:messages].to_json)
|
||||
|
||||
response = yield
|
||||
executed = true
|
||||
|
||||
# Output just the message for cleaner Langfuse display
|
||||
span.set_attribute('langfuse.observation.output', response[:message] || response.to_json)
|
||||
end
|
||||
response
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
executed ? response : yield
|
||||
end
|
||||
|
||||
def record_generation(chat, message, model)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
|
||||
|
||||
tracer.in_span("llm.#{event_name}.generation") do |span|
|
||||
span.set_attribute('gen_ai.system', 'openai')
|
||||
span.set_attribute('gen_ai.request.model', model)
|
||||
span.set_attribute('gen_ai.usage.input_tokens', message.input_tokens)
|
||||
span.set_attribute('gen_ai.usage.output_tokens', message.output_tokens) if message.respond_to?(:output_tokens)
|
||||
span.set_attribute('langfuse.observation.input', format_chat_messages(chat))
|
||||
span.set_attribute('langfuse.observation.output', message.content.to_s) if message.respond_to?(:content)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to record generation: #{e.message}"
|
||||
end
|
||||
|
||||
def format_chat_messages(chat)
|
||||
chat.messages[0...-1].map { |m| { role: m.role.to_s, content: m.content.to_s } }.to_json
|
||||
end
|
||||
end
|
||||
@@ -21,6 +21,10 @@ module ChatwootApp
|
||||
enterprise? && GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud'
|
||||
end
|
||||
|
||||
def self.self_hosted_enterprise?
|
||||
enterprise? && !chatwoot_cloud? && GlobalConfig.get_value('INSTALLATION_PRICING_PLAN') == 'enterprise'
|
||||
end
|
||||
|
||||
def self.custom?
|
||||
@custom ||= root.join('custom').exist?
|
||||
end
|
||||
|
||||
@@ -31,5 +31,10 @@ General guidelines:
|
||||
- Move the conversation forward
|
||||
- Do not invent product details, policies, or links that weren't mentioned
|
||||
- Reply in the customer's language
|
||||
{% if has_search_tool %}
|
||||
|
||||
**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information.
|
||||
**Use the search_documentation tool first** to find relevant information before composing your reply. This ensures your response is accurate and based on actual company documentation.
|
||||
{% endif %}
|
||||
|
||||
Output only the reply.
|
||||
|
||||
@@ -49,4 +49,7 @@ module Redis::RedisKeys
|
||||
# Track conversation assignments to agents for rate limiting
|
||||
ASSIGNMENT_KEY = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::CONVERSATION::%<conversation_id>d'.freeze
|
||||
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::*'.freeze
|
||||
|
||||
## Account Email Rate Limiting
|
||||
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
|
||||
end
|
||||
|
||||
@@ -23,22 +23,45 @@ class Seeders::AccountSeeder
|
||||
set_up_users
|
||||
seed_labels
|
||||
seed_canned_responses
|
||||
seed_macros
|
||||
seed_inboxes
|
||||
seed_contacts
|
||||
seed_portals
|
||||
seed_captain_assistants
|
||||
finalize_seeding
|
||||
end
|
||||
|
||||
def set_up_account
|
||||
# Delete all existing data before seeding
|
||||
@account.macros.destroy_all
|
||||
@account.canned_responses.destroy_all
|
||||
@account.teams.destroy_all
|
||||
@account.conversations.destroy_all
|
||||
@account.labels.destroy_all
|
||||
@account.portals.destroy_all
|
||||
@account.inboxes.destroy_all
|
||||
@account.contacts.destroy_all
|
||||
@account.custom_roles.destroy_all if @account.respond_to?(:custom_roles)
|
||||
|
||||
# Delete Captain Assistants if Enterprise feature is available
|
||||
return unless defined?(Captain::Assistant)
|
||||
|
||||
Captain::Assistant.where(account_id: @account.id).destroy_all
|
||||
end
|
||||
|
||||
def seed_teams
|
||||
@account_data['teams'].each do |team_name|
|
||||
@account.teams.create!(name: team_name)
|
||||
@account_data['teams'].each do |team_data|
|
||||
if team_data.is_a?(String)
|
||||
# Support legacy format (just team name)
|
||||
@account.teams.create!(name: team_data)
|
||||
else
|
||||
# New format with name and description
|
||||
@account.teams.create!(
|
||||
name: team_data['name'],
|
||||
description: team_data['description'],
|
||||
allow_auto_assign: team_data['allow_auto_assign'] != false
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -103,9 +126,68 @@ class Seeders::AccountSeeder
|
||||
@account.custom_roles.find_by(name: role_name)
|
||||
end
|
||||
|
||||
def seed_canned_responses(count: 50)
|
||||
count.times do
|
||||
@account.canned_responses.create(content: Faker::Quote.fortune_cookie, short_code: Faker::Alphanumeric.alpha(number: 10))
|
||||
def seed_canned_responses
|
||||
return unless @account_data['canned_responses'].present?
|
||||
|
||||
@account_data['canned_responses'].each do |response|
|
||||
@account.canned_responses.create!(response)
|
||||
end
|
||||
end
|
||||
|
||||
def seed_macros
|
||||
return unless @account_data['macros'].present?
|
||||
|
||||
@account_data['macros'].each do |macro_data|
|
||||
created_by = User.from_email(macro_data['created_by']) if macro_data['created_by'].present?
|
||||
actions = macro_data['actions'].map do |action|
|
||||
process_macro_action(action)
|
||||
end.compact # Remove nil actions (when team/agent not found)
|
||||
|
||||
# Only create macro if there are valid actions
|
||||
if actions.any?
|
||||
@account.macros.create!(
|
||||
name: macro_data['name'],
|
||||
visibility: macro_data['visibility'] || 'global',
|
||||
created_by: created_by,
|
||||
actions: actions
|
||||
)
|
||||
else
|
||||
Rails.logger.warn("Macro '#{macro_data['name']}' has no valid actions, skipping")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def seed_portals
|
||||
return unless @account_data['portals'].present?
|
||||
|
||||
@account_data['portals'].each do |portal_data|
|
||||
portal = @account.portals.create!(
|
||||
name: portal_data['name'],
|
||||
slug: portal_data['slug'],
|
||||
page_title: portal_data['page_title'],
|
||||
header_text: portal_data['header_text'],
|
||||
color: portal_data['color'],
|
||||
config: portal_data['config']
|
||||
)
|
||||
|
||||
seed_portal_categories(portal, portal_data['categories']) if portal_data['categories'].present?
|
||||
end
|
||||
end
|
||||
|
||||
def seed_captain_assistants
|
||||
return unless @account_data['captain_assistants'].present?
|
||||
return unless defined?(Captain::Assistant)
|
||||
|
||||
@account_data['captain_assistants'].each do |assistant_data|
|
||||
assistant = Captain::Assistant.create!(
|
||||
account: @account,
|
||||
name: assistant_data['name'],
|
||||
description: assistant_data['description'],
|
||||
config: assistant_data['config'] || {}
|
||||
)
|
||||
|
||||
seed_captain_documents(assistant, assistant_data['documents']) if assistant_data['documents'].present?
|
||||
seed_captain_scenarios(assistant, assistant_data['scenarios']) if assistant_data['scenarios'].present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -155,4 +237,106 @@ class Seeders::AccountSeeder
|
||||
def seed_inboxes
|
||||
Seeders::InboxSeeder.new(account: @account, company_data: @account_data[:company]).perform!
|
||||
end
|
||||
|
||||
def process_macro_action(action)
|
||||
processed_action = { 'action_name' => action['action_name'] }
|
||||
|
||||
case action['action_name']
|
||||
when 'assign_team'
|
||||
team = @account.teams.find_by('name LIKE ?', "%#{action['action_params'][0]}%")
|
||||
if team
|
||||
processed_action['action_params'] = [team.id]
|
||||
else
|
||||
Rails.logger.warn("Macro: Team not found matching '#{action['action_params'][0]}', skipping assign_team action")
|
||||
return nil # Skip this action
|
||||
end
|
||||
when 'assign_agent'
|
||||
user = User.from_email(action['action_params'][0])
|
||||
if user
|
||||
processed_action['action_params'] = [user.id]
|
||||
else
|
||||
Rails.logger.warn("Macro: User not found '#{action['action_params'][0]}', skipping assign_agent action")
|
||||
return nil # Skip this action
|
||||
end
|
||||
else
|
||||
processed_action['action_params'] = action['action_params']
|
||||
end
|
||||
|
||||
processed_action
|
||||
end
|
||||
|
||||
def seed_portal_categories(portal, categories_data)
|
||||
categories_data.each do |category_data|
|
||||
category = portal.categories.create!(
|
||||
account: @account,
|
||||
name: category_data['name'],
|
||||
slug: category_data['slug'],
|
||||
description: category_data['description'],
|
||||
locale: category_data['locale'] || 'en',
|
||||
position: category_data['position']
|
||||
)
|
||||
|
||||
seed_category_articles(portal, category, category_data['articles']) if category_data['articles'].present?
|
||||
end
|
||||
end
|
||||
|
||||
def seed_category_articles(portal, category, articles_data)
|
||||
articles_data.each do |article_data|
|
||||
author = User.from_email(article_data['author']) if article_data['author'].present?
|
||||
author ||= @account.users.administrators.first
|
||||
|
||||
portal.articles.create!(
|
||||
account: @account,
|
||||
category: category,
|
||||
author: author,
|
||||
title: article_data['title'],
|
||||
description: article_data['description'],
|
||||
content: article_data['content'],
|
||||
status: article_data['status'] || 'published',
|
||||
locale: article_data['locale'] || 'en'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def seed_captain_documents(assistant, documents_data)
|
||||
documents_data.each do |doc_data|
|
||||
Captain::Document.create!(
|
||||
account: @account,
|
||||
assistant: assistant,
|
||||
name: doc_data['name'],
|
||||
external_link: doc_data['external_link'],
|
||||
content: doc_data['content'],
|
||||
status: doc_data['status'] || 'available'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def seed_captain_scenarios(assistant, scenarios_data)
|
||||
scenarios_data.each do |scenario_data|
|
||||
Captain::Scenario.create!(
|
||||
account: @account,
|
||||
assistant: assistant,
|
||||
title: scenario_data['title'],
|
||||
description: scenario_data['description'],
|
||||
instruction: scenario_data['instruction'],
|
||||
enabled: scenario_data['enabled'] != false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def finalize_seeding
|
||||
# Reload account to ensure all associations are fresh
|
||||
@account.reload
|
||||
|
||||
# Reset cache keys for labels, inboxes, teams
|
||||
@account.reset_cache_keys if @account.respond_to?(:reset_cache_keys)
|
||||
|
||||
# Clear any Rails cache related to this account
|
||||
Rails.cache.delete("account/#{@account.id}")
|
||||
|
||||
# Force reload of all account users to refresh their associations
|
||||
@account.account_users.each(&:reload)
|
||||
|
||||
Rails.logger.info("Seed completed for account #{@account.id} - #{@account.name}")
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user