feat(v5): New expanded layout for chat list
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contact: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false },
|
||||
enableSelection: { type: Boolean, default: true },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectConversation']);
|
||||
|
||||
const hovered = ref(false);
|
||||
|
||||
const onThumbnailHover = () => {
|
||||
hovered.value = !props.hideThumbnail;
|
||||
};
|
||||
|
||||
const onThumbnailLeave = () => {
|
||||
hovered.value = false;
|
||||
};
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
emit('selectConversation', value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-center flex-shrink-0"
|
||||
@mouseenter="onThumbnailHover"
|
||||
@mouseleave="onThumbnailLeave"
|
||||
>
|
||||
<Avatar
|
||||
v-if="!hideThumbnail"
|
||||
:name="contact.name"
|
||||
:src="contact.thumbnail"
|
||||
:size="24"
|
||||
:status="contact.availability_status"
|
||||
hide-offline-status
|
||||
>
|
||||
<template v-if="enableSelection" #overlay>
|
||||
<div
|
||||
v-if="hovered || selected"
|
||||
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
</template>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import UnreadBadge from './UnreadBadge.vue';
|
||||
|
||||
defineProps({
|
||||
lastMessage: { type: Object, default: null },
|
||||
voiceCallStatus: { type: String, default: '' },
|
||||
voiceCallDirection: { type: String, default: '' },
|
||||
unreadCount: { type: Number, default: 0 },
|
||||
showExpandedPreview: { type: Boolean, default: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_auto] gap-1.5"
|
||||
:class="showExpandedPreview ? 'items-end' : 'items-center'"
|
||||
>
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallStatus"
|
||||
key="voice-status-row"
|
||||
:status="voiceCallStatus"
|
||||
:direction="voiceCallDirection"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessage"
|
||||
key="message-preview"
|
||||
:message="lastMessage"
|
||||
:multi-line="showExpandedPreview"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
>
|
||||
<Icon icon="i-lucide-info" class="size-3.5" />
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
|
||||
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
|
||||
defineProps({
|
||||
contactName: { type: String, required: true },
|
||||
timestamp: { type: Number, required: true },
|
||||
createdAt: { type: Number, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-[1fr_auto] items-center gap-2 h-5">
|
||||
<h4 class="text-sm my-0 capitalize truncate text-n-slate-12 font-medium">
|
||||
{{ contactName }}
|
||||
</h4>
|
||||
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="timestamp"
|
||||
:created-at-timestamp="createdAt"
|
||||
class="font-440 !text-xs shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+164
-68
@@ -1,95 +1,191 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
inject,
|
||||
nextTick,
|
||||
useSlots,
|
||||
watch,
|
||||
useAttrs,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationLabels: {
|
||||
labels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
disableToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const WIDTH_CONFIG = Object.freeze({
|
||||
DEFAULT_WIDTH: 80,
|
||||
CHAR_WIDTH: {
|
||||
SHORT: 8, // For labels <= 5 chars
|
||||
LONG: 6, // For labels > 5 chars
|
||||
},
|
||||
BASE_WIDTH: 12, // dot + gap
|
||||
THRESHOLD: 5, // character length threshold
|
||||
});
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const containerRef = ref(null);
|
||||
const maxLabels = ref(1);
|
||||
const attrs = useAttrs();
|
||||
const slots = useSlots();
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountLabels = useMapGetter('labels/getLabels');
|
||||
|
||||
const activeLabels = computed(() => {
|
||||
const labelSet = new Set(props.conversationLabels);
|
||||
return props.accountLabels?.filter(({ title }) => labelSet.has(title));
|
||||
return accountLabels.value.filter(({ title }) =>
|
||||
props.labels.includes(title)
|
||||
);
|
||||
});
|
||||
|
||||
const calculateLabelWidth = ({ title = '' }) => {
|
||||
const charWidth =
|
||||
title.length > WIDTH_CONFIG.THRESHOLD
|
||||
? WIDTH_CONFIG.CHAR_WIDTH.LONG
|
||||
: WIDTH_CONFIG.CHAR_WIDTH.SHORT;
|
||||
const showAllLabels = ref(false);
|
||||
const showExpandLabelButton = ref(false);
|
||||
const labelPosition = ref(-1);
|
||||
const labelContainer = ref(null);
|
||||
|
||||
return title.length * charWidth + WIDTH_CONFIG.BASE_WIDTH;
|
||||
};
|
||||
|
||||
const getAverageWidth = labels => {
|
||||
if (!labels.length) return WIDTH_CONFIG.DEFAULT_WIDTH;
|
||||
|
||||
const totalWidth = labels.reduce(
|
||||
(sum, label) => sum + calculateLabelWidth(label),
|
||||
0
|
||||
);
|
||||
|
||||
return totalWidth / labels.length;
|
||||
};
|
||||
|
||||
const visibleLabels = computed(() =>
|
||||
activeLabels.value?.slice(0, maxLabels.value)
|
||||
// Show if there are labels OR if before slot exists
|
||||
const showSection = computed(
|
||||
() => activeLabels.value.length > 0 || !!slots.before
|
||||
);
|
||||
|
||||
const updateVisibleLabels = () => {
|
||||
if (!containerRef.value) return;
|
||||
const computeVisibleLabelPosition = () => {
|
||||
const container = labelContainer.value;
|
||||
if (!container || activeLabels.value.length === 0) {
|
||||
showExpandLabelButton.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = containerRef.value.offsetWidth;
|
||||
const avgWidth = getAverageWidth(activeLabels.value);
|
||||
const labels = container.querySelectorAll('[data-label]');
|
||||
if (labels.length === 0) {
|
||||
showExpandLabelButton.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
maxLabels.value = Math.max(1, Math.floor(containerWidth / avgWidth));
|
||||
// Early exit if all labels are visible
|
||||
if (showAllLabels.value) return;
|
||||
|
||||
const beforeSlot = container.querySelector('[data-before-slot]');
|
||||
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
|
||||
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
|
||||
|
||||
let totalWidth = 0;
|
||||
const labelsArray = Array.from(labels);
|
||||
|
||||
// Find last visible label index using some() - stops early on overflow
|
||||
const overflowIndex = labelsArray.findIndex(label => {
|
||||
totalWidth += label.offsetWidth + 6;
|
||||
return totalWidth > availableWidth;
|
||||
});
|
||||
|
||||
const visibleIndex =
|
||||
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
|
||||
|
||||
labelPosition.value = visibleIndex;
|
||||
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
|
||||
};
|
||||
|
||||
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
|
||||
|
||||
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
|
||||
|
||||
// Recalculate when parent triggers (e.g., tab changes in ChatList)
|
||||
// Needed because inbox conversation view shows metadata inside CardLabels, affecting available width
|
||||
const recalculateKey = inject('recalculateLabelsKey', null);
|
||||
|
||||
if (recalculateKey) {
|
||||
watch(recalculateKey, () => nextTick(throttledCalculate));
|
||||
}
|
||||
|
||||
const hiddenLabelsCount = computed(() => {
|
||||
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
|
||||
return activeLabels.value.length - labelPosition.value - 1;
|
||||
});
|
||||
|
||||
// Check if all labels are hidden (none visible)
|
||||
const allLabelsHidden = computed(() => {
|
||||
return labelPosition.value === -1 && activeLabels.value.length > 0;
|
||||
});
|
||||
|
||||
// Label text for button when disableToggle is true and all labels are hidden
|
||||
const labelsCountText = computed(() => {
|
||||
if (props.disableToggle && allLabelsHidden.value) {
|
||||
return t('CONVERSATION.CARD.LABELS_COUNT', {
|
||||
count: activeLabels.value.length,
|
||||
});
|
||||
}
|
||||
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
|
||||
return hiddenLabelsCount.value;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const hiddenLabelsTooltip = computed(() => {
|
||||
if (!props.disableToggle) return '';
|
||||
// When all labels are hidden, show all label titles
|
||||
if (allLabelsHidden.value) {
|
||||
return activeLabels.value.map(label => label.title).join(', ');
|
||||
}
|
||||
if (!showExpandLabelButton.value) return '';
|
||||
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
|
||||
return hiddenLabels.map(label => label.title).join(', ');
|
||||
});
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (props.disableToggle && hiddenLabelsTooltip.value) {
|
||||
return hiddenLabelsTooltip.value;
|
||||
}
|
||||
return showAllLabels.value
|
||||
? t('CONVERSATION.CARD.HIDE_LABELS')
|
||||
: t('CONVERSATION.CARD.SHOW_LABELS');
|
||||
});
|
||||
|
||||
const onShowLabels = e => {
|
||||
e.stopPropagation();
|
||||
if (props.disableToggle) return;
|
||||
showAllLabels.value = !showAllLabels.value;
|
||||
nextTick(() => computeVisibleLabelPosition());
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
v-resize="updateVisibleLabels"
|
||||
class="flex items-center gap-2.5 w-full min-w-0 h-6 overflow-hidden"
|
||||
v-if="showSection"
|
||||
ref="labelContainer"
|
||||
v-bind="attrs"
|
||||
v-resize="throttledCalculate"
|
||||
data-labels-container
|
||||
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
|
||||
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
|
||||
>
|
||||
<template v-for="(label, index) in visibleLabels" :key="label.id">
|
||||
<div
|
||||
class="flex items-center gap-1.5 min-w-0"
|
||||
:class="[
|
||||
index !== visibleLabels.length - 1
|
||||
? 'flex-shrink-0 text-ellipsis'
|
||||
: 'flex-shrink',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:style="{ backgroundColor: label.color }"
|
||||
class="size-1.5 rounded-full flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
class="text-sm text-n-slate-10 whitespace-nowrap"
|
||||
:class="{ truncate: index === visibleLabels.length - 1 }"
|
||||
>
|
||||
{{ label.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<slot name="before" />
|
||||
|
||||
<Label
|
||||
v-for="(label, index) in activeLabels"
|
||||
:key="label ? label.id : index"
|
||||
data-label
|
||||
:label="label"
|
||||
compact
|
||||
:class="{
|
||||
'invisible absolute': !showAllLabels && index > labelPosition,
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
|
||||
v-tooltip.top="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1000, hide: 0 },
|
||||
}"
|
||||
:label="labelsCountText"
|
||||
xs
|
||||
slate
|
||||
:no-animation="disableToggle"
|
||||
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
|
||||
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
|
||||
:class="{ 'cursor-default': disableToggle }"
|
||||
@click="onShowLabels"
|
||||
/>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
import CardStatusIcon from './CardStatusIcon.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
inbox: { type: Object, required: true },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inline: { type: Boolean, default: false },
|
||||
isLabelsEmpty: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
);
|
||||
|
||||
const showSection = computed(() => {
|
||||
if (props.inline) {
|
||||
return (
|
||||
hasSlaPolicyId.value ||
|
||||
props.chat.priority ||
|
||||
(props.showAssignee && props.assignee.name)
|
||||
);
|
||||
}
|
||||
return (
|
||||
props.showInboxName ||
|
||||
(props.showAssignee && props.assignee.name) ||
|
||||
props.chat.priority ||
|
||||
props.chat.status ||
|
||||
hasSlaPolicyId.value
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Full meta section (when not inline) -->
|
||||
<div
|
||||
v-if="showSection && !inline"
|
||||
class="flex items-center justify-between min-w-0 gap-8 h-6 mb-1"
|
||||
>
|
||||
<div class="flex-1 min-w-0 flex items-center gap-0.5">
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<CardPriorityIcon
|
||||
v-if="chat.priority"
|
||||
:priority="chat.priority"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-if="showAssignee && assignee.name"
|
||||
v-tooltip.top="{
|
||||
content: assignee.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<CardStatusIcon v-if="chat.status" :status="chat.status" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline meta section (compact mode for cases in inbox and label filter view) -->
|
||||
<div
|
||||
v-else-if="showSection && inline"
|
||||
data-before-slot
|
||||
class="flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<CardPriorityIcon
|
||||
v-if="chat.priority"
|
||||
:priority="chat.priority"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-if="showAssignee && assignee.name"
|
||||
v-tooltip.top="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<CardStatusIcon v-if="chat.status" :status="chat.status" />
|
||||
</div>
|
||||
<div v-if="hasSlaPolicyId" class="rounded-lg h-2 w-px bg-n-strong mx-0.5" />
|
||||
<SLACardLabel v-if="hasSlaPolicyId" ref="slaCardLabel" :chat="chat" />
|
||||
<div v-if="!isLabelsEmpty" class="rounded-lg h-2 w-px bg-n-strong mx-0.5" />
|
||||
</div>
|
||||
</template>
|
||||
+30
-195
@@ -1,207 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
|
||||
defineProps({
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const icons = {
|
||||
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
|
||||
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
|
||||
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
|
||||
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
|
||||
};
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.priority && icons[props.priority]) {
|
||||
return icons[props.priority];
|
||||
}
|
||||
return props.showEmpty ? 'i-woot-priority-empty' : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Medium Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- High Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.HIGH"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Urgent Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.URGENT"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<Icon
|
||||
v-tooltip.top="{
|
||||
content: priority,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4 text-n-slate-5"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { CONVERSATION_STATUS } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const icons = {
|
||||
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
|
||||
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
|
||||
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
|
||||
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
|
||||
};
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.status && icons[props.status]) {
|
||||
return icons[props.status];
|
||||
}
|
||||
return props.showEmpty ? 'i-woot-status-empty' : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon
|
||||
v-tooltip.top="{
|
||||
content: status,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import CardMetaSection from './CardMetaSection.vue';
|
||||
import CardAvatar from './CardAvatar.vue';
|
||||
import CardHeader from './CardHeader.vue';
|
||||
import CardContent from './CardContent.vue';
|
||||
import CardLabels from './CardLabels.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
enableSelection: { type: Boolean, default: true },
|
||||
isInboxView: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectConversation', 'click', 'contextmenu']);
|
||||
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
const showExpandedPreview = computed(
|
||||
() => props.compact && !showLabelsSection.value
|
||||
);
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const unreadCount = computed(() => props.chat?.unread_count);
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
);
|
||||
|
||||
const onSelectConversation = checked => {
|
||||
emit('selectConversation', checked);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full cursor-pointer group transition-colors duration-150"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
|
||||
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
|
||||
'px-0 py-3': compact,
|
||||
'px-2 pt-2.5 pb-3 hover:bg-n-alpha-1 rounded-lg': !compact,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<div class="min-w-0 w-full">
|
||||
<CardMetaSection
|
||||
v-if="!isInboxView"
|
||||
:chat="chat"
|
||||
:inbox="inbox"
|
||||
:show-inbox-name="showInboxName"
|
||||
:show-assignee="showAssignee"
|
||||
:assignee="assignee"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="grid gap-2.5 items-start"
|
||||
:class="{
|
||||
'mt-0.5 grid-cols-1': compact,
|
||||
'grid-cols-[auto,1fr]': !compact,
|
||||
}"
|
||||
>
|
||||
<CardAvatar
|
||||
v-if="!compact"
|
||||
:contact="currentContact"
|
||||
:selected="selected"
|
||||
:enable-selection="enableSelection"
|
||||
:hide-thumbnail="hideThumbnail"
|
||||
class="mt-0.5"
|
||||
@select-conversation="onSelectConversation"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 flex flex-col gap-1.5">
|
||||
<div class="min-w-0 flex flex-col gap-px">
|
||||
<CardHeader
|
||||
v-if="!compact"
|
||||
:contact-name="currentContact.name"
|
||||
:timestamp="chat.timestamp"
|
||||
:created-at="chat.created_at"
|
||||
/>
|
||||
|
||||
<CardContent
|
||||
:last-message="lastMessageInChat"
|
||||
:voice-call-status="voiceCallData.status"
|
||||
:voice-call-direction="voiceCallData.direction"
|
||||
:unread-count="unreadCount"
|
||||
:show-expanded-preview="showExpandedPreview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardLabels
|
||||
v-if="showLabelsSection || isInboxView"
|
||||
:labels="chat.labels"
|
||||
>
|
||||
<template #before>
|
||||
<CardMetaSection
|
||||
v-if="isInboxView"
|
||||
:chat="chat"
|
||||
:inbox="inbox"
|
||||
:show-assignee="showAssignee"
|
||||
:assignee="assignee"
|
||||
:is-labels-empty="chat.labels.length === 0"
|
||||
inline
|
||||
/>
|
||||
<SLACardLabel
|
||||
v-else-if="hasSlaPolicyId"
|
||||
ref="slaCardLabel"
|
||||
data-before-slot
|
||||
:chat="chat"
|
||||
/>
|
||||
</template>
|
||||
</CardLabels>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import CardAvatar from './CardAvatar.vue';
|
||||
import CardContent from './CardContent.vue';
|
||||
import CardLabels from './CardLabels.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
|
||||
import CardStatusIcon from './CardStatusIcon.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
isInboxView: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'click',
|
||||
'contextmenu',
|
||||
]);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
);
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
if (value) {
|
||||
emit('selectConversation', value);
|
||||
} else {
|
||||
emit('deSelectConversation', value);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative cursor-pointer group transition-colors duration-150 grid gap-4 items-center px-2 h-12"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
|
||||
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
|
||||
'hover:bg-n-alpha-1 rounded-lg': !isActiveChat && !selected,
|
||||
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
|
||||
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<!-- LEFT SECTION -->
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardPriorityIcon :priority="chat.priority" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<Avatar
|
||||
v-if="showAssignee && assignee.name"
|
||||
v-tooltip.top="{
|
||||
content: assignee.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-woot-empty-assignee"
|
||||
class="size-4 text-n-slate-7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardStatusIcon :status="chat.status" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div v-if="!isInboxView" class="w-20 flex-shrink-0">
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div
|
||||
v-tooltip.top="{
|
||||
content: chat.id,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-hash"
|
||||
class="size-3.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-label-small text-n-slate-11 truncate">
|
||||
{{ chat.id }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CardAvatar
|
||||
:contact="currentContact"
|
||||
:selected="false"
|
||||
:enable-selection="false"
|
||||
:hide-thumbnail="false"
|
||||
/>
|
||||
|
||||
<h4
|
||||
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
|
||||
<CardContent
|
||||
:last-message="lastMessageInChat"
|
||||
:voice-call-status="voiceCallData.status"
|
||||
:voice-call-direction="voiceCallData.direction"
|
||||
:unread-count="unreadCount"
|
||||
:show-expanded-preview="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT SECTION -->
|
||||
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
|
||||
<div v-if="showLabelsSection" class="min-w-0 w-full">
|
||||
<CardLabels
|
||||
:labels="chat.labels"
|
||||
disable-toggle
|
||||
class="my-0 [&>div]:justify-end justify-end"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
|
||||
<SLACardLabel ref="slaCardLabel" :chat="chat" />
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 w-[4.375rem] text-end">
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
class="font-440 !text-xs text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
<script setup>
|
||||
import { computed, ref, inject } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import ConversationContextMenu from 'dashboard/components/widgets/conversation/contextMenu/Index.vue';
|
||||
import ConversationCardExpanded from './ConversationCardExpanded.vue';
|
||||
// import ConversationCardCompact from './ConversationCardCompact.vue';
|
||||
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeLabel: { type: String, default: '' },
|
||||
chat: { type: Object, default: () => ({}) },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
conversationType: { type: String, default: '' },
|
||||
selected: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
enableContextMenu: { type: Boolean, default: false },
|
||||
allowedContextMenuOptions: { type: Array, default: () => [] },
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
enableSelection: { type: Boolean, default: true },
|
||||
isExpandedLayout: { type: Boolean, default: false },
|
||||
isPreviousConversations: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'contextMenuToggle',
|
||||
'assignAgent',
|
||||
'assignLabel',
|
||||
'removeLabel',
|
||||
'assignTeam',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'updateConversationStatus',
|
||||
'deleteConversation',
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const isLargeScreen = inject('isLargeScreen', ref(false));
|
||||
|
||||
// Show expanded only when: isExpandedLayout=true AND screen >= 1024px
|
||||
const showExpanded = computed(() => {
|
||||
return props.isExpandedLayout && isLargeScreen.value;
|
||||
});
|
||||
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const contactGetter = useMapGetter('contacts/getContact');
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const chatMetadata = computed(() => props.chat.meta || {});
|
||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||
const senderId = computed(() => chatMetadata.value.sender?.id);
|
||||
const currentContact = computed(() =>
|
||||
senderId.value ? contactGetter.value(senderId.value) : {}
|
||||
);
|
||||
const hasActiveInbox = computed(() => activeInbox.value);
|
||||
|
||||
const isActiveChat = computed(() => currentChat.value.id === props.chat.id);
|
||||
const inbox = computed(() => {
|
||||
const inboxId = props.chat.inbox_id;
|
||||
return inboxId ? inboxGetter.value(inboxId) : {};
|
||||
});
|
||||
|
||||
const showInboxName = computed(() => inboxesList.value.length > 1);
|
||||
|
||||
const conversationPath = computed(() => {
|
||||
return frontendURL(
|
||||
conversationUrl({
|
||||
accountId: accountId.value,
|
||||
activeInbox: activeInbox.value,
|
||||
id: props.chat.id,
|
||||
label: props.activeLabel,
|
||||
teamId: props.teamId,
|
||||
conversationType: props.conversationType,
|
||||
foldersId: props.foldersId,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const onCardClick = e => {
|
||||
const path = conversationPath.value;
|
||||
if (!path) return;
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveChat.value) return;
|
||||
router.push({ path });
|
||||
};
|
||||
|
||||
const onSelectConversation = checked => {
|
||||
if (checked) {
|
||||
emit('selectConversation', props.chat.id, inbox.value.id);
|
||||
} else {
|
||||
emit('deSelectConversation', props.chat.id, inbox.value.id);
|
||||
}
|
||||
};
|
||||
|
||||
const openContextMenu = e => {
|
||||
if (!props.enableContextMenu) return;
|
||||
e.preventDefault();
|
||||
emit('contextMenuToggle', true);
|
||||
contextMenu.value.x = e.pageX || e.clientX;
|
||||
contextMenu.value.y = e.pageY || e.clientY;
|
||||
showContextMenu.value = true;
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
emit('contextMenuToggle', false);
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value.x = null;
|
||||
contextMenu.value.y = null;
|
||||
};
|
||||
|
||||
const onUpdateConversation = (status, snoozedUntil) => {
|
||||
closeContextMenu();
|
||||
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
|
||||
};
|
||||
|
||||
const onAssignAgent = agent => {
|
||||
emit('assignAgent', agent, [props.chat.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignLabel = label => {
|
||||
emit('assignLabel', [label.title], [props.chat.id]);
|
||||
};
|
||||
|
||||
const onRemoveLabel = label => {
|
||||
emit('removeLabel', [label.title], [props.chat.id]);
|
||||
};
|
||||
|
||||
const onAssignTeam = team => {
|
||||
emit('assignTeam', team, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsUnread = () => {
|
||||
emit('markAsUnread', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsRead = () => {
|
||||
emit('markAsRead', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const assignPriority = priority => {
|
||||
emit('assignPriority', priority, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const deleteConversation = () => {
|
||||
emit('deleteConversation', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Expanded: Only when isExpandedLayout=true AND screen >= 1024px -->
|
||||
<ConversationCardExpanded
|
||||
v-if="showExpanded"
|
||||
:chat="chat"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
:inbox="inbox"
|
||||
:selected="selected"
|
||||
:is-active-chat="isActiveChat"
|
||||
:show-assignee="showAssignee"
|
||||
:show-inbox-name="showInboxName"
|
||||
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
|
||||
@select-conversation="onSelectConversation"
|
||||
@de-select-conversation="onSelectConversation"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu"
|
||||
/>
|
||||
|
||||
<!-- Compact: All other cases (mobile OR isExpandedLayout=false) - Using older ConversationCard -->
|
||||
<ConversationCard
|
||||
v-else
|
||||
:active-label="activeLabel"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:chat="chat"
|
||||
:conversation-type="conversationType"
|
||||
:selected="selected"
|
||||
:show-assignee="showAssignee"
|
||||
:hide-thumbnail="hideThumbnail"
|
||||
:compact="compact"
|
||||
:enable-context-menu="enableContextMenu"
|
||||
:allowed-context-menu-options="allowedContextMenuOptions"
|
||||
@select-conversation="
|
||||
(chatId, inboxId) => emit('selectConversation', chatId, inboxId)
|
||||
"
|
||||
@de-select-conversation="
|
||||
(chatId, inboxId) => emit('deSelectConversation', chatId, inboxId)
|
||||
"
|
||||
@assign-agent="
|
||||
(agent, conversationIds) => emit('assignAgent', agent, conversationIds)
|
||||
"
|
||||
@assign-label="
|
||||
(labels, conversationIds) => emit('assignLabel', labels, conversationIds)
|
||||
"
|
||||
@remove-label="
|
||||
(labels, conversationIds) => emit('removeLabel', labels, conversationIds)
|
||||
"
|
||||
@assign-team="
|
||||
(team, conversationId) => emit('assignTeam', team, conversationId)
|
||||
"
|
||||
@mark-as-unread="conversationId => emit('markAsUnread', conversationId)"
|
||||
@mark-as-read="conversationId => emit('markAsRead', conversationId)"
|
||||
@assign-priority="
|
||||
(priority, conversationId) =>
|
||||
emit('assignPriority', priority, conversationId)
|
||||
"
|
||||
@update-conversation-status="
|
||||
(conversationId, status, snoozedUntil) =>
|
||||
emit('updateConversationStatus', conversationId, status, snoozedUntil)
|
||||
"
|
||||
@delete-conversation="
|
||||
conversationId => emit('deleteConversation', conversationId)
|
||||
"
|
||||
@context-menu-toggle="state => emit('contextMenuToggle', state)"
|
||||
/>
|
||||
<!-- New ConversationCardCompact
|
||||
<ConversationCardCompact
|
||||
v-else
|
||||
:chat="chat"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
:inbox="inbox"
|
||||
:selected="selected"
|
||||
:is-active-chat="isActiveChat"
|
||||
:compact="compact"
|
||||
:show-assignee="showAssignee"
|
||||
:show-inbox-name="showInboxName"
|
||||
:hide-thumbnail="hideThumbnail"
|
||||
:enable-selection="enableSelection"
|
||||
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
|
||||
@select-conversation="onSelectConversation"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu"
|
||||
/>
|
||||
-->
|
||||
|
||||
<ContextMenu
|
||||
v-if="showContextMenu"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
@close="closeContextMenu"
|
||||
>
|
||||
<ConversationContextMenu
|
||||
:status="chat.status"
|
||||
:inbox-id="inbox.id"
|
||||
:priority="chat.priority"
|
||||
:chat-id="chat.id"
|
||||
:has-unread-messages="chat.unread_count > 0"
|
||||
:conversation-labels="chat.labels"
|
||||
:conversation-url="conversationPath"
|
||||
:allowed-options="allowedContextMenuOptions"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@remove-label="onRemoveLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showMessageType: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
defaultEmptyMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
multiLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
const attachmentIcons = {
|
||||
image: 'i-lucide-image',
|
||||
audio: 'i-lucide-headphones',
|
||||
video: 'i-lucide-video',
|
||||
file: 'i-lucide-file',
|
||||
location: 'i-lucide-map-pin',
|
||||
fallback: 'i-lucide-link-2',
|
||||
};
|
||||
|
||||
const messageByAgent = computed(() => {
|
||||
const { message_type: messageType } = props.message;
|
||||
return messageType === MESSAGE_TYPE.OUTGOING;
|
||||
});
|
||||
|
||||
const isMessageAnActivity = computed(() => {
|
||||
const { message_type: messageType } = props.message;
|
||||
return messageType === MESSAGE_TYPE.ACTIVITY;
|
||||
});
|
||||
|
||||
const isMessagePrivate = computed(() => {
|
||||
const { private: isPrivate } = props.message;
|
||||
return isPrivate;
|
||||
});
|
||||
|
||||
const parsedLastMessage = computed(() => {
|
||||
const { content_attributes: contentAttributes } = props.message;
|
||||
const { email: { subject } = {} } = contentAttributes || {};
|
||||
return getPlainText(subject || props.message.content);
|
||||
});
|
||||
|
||||
const lastMessageFileType = computed(() => {
|
||||
const [{ file_type: fileType } = {}] = props.message.attachments;
|
||||
return fileType;
|
||||
});
|
||||
|
||||
const attachmentIcon = computed(() => {
|
||||
return attachmentIcons[lastMessageFileType.value];
|
||||
});
|
||||
|
||||
const attachmentMessageContent = computed(() => {
|
||||
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
|
||||
});
|
||||
|
||||
const isMessageSticker = computed(() => {
|
||||
return props.message && props.message.content_type === 'sticker';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-w-0 text-sm"
|
||||
:class="
|
||||
multiLine
|
||||
? 'flex items-start gap-1'
|
||||
: 'grid grid-cols-[auto_1fr] items-center gap-1'
|
||||
"
|
||||
>
|
||||
<template v-if="showMessageType && !multiLine">
|
||||
<Icon
|
||||
v-if="isMessagePrivate"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="messageByAgent"
|
||||
icon="i-lucide-undo-2"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
icon="i-lucide-info"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<span
|
||||
class="min-w-0 text-body-main"
|
||||
:class="multiLine ? 'line-clamp-2' : 'truncate'"
|
||||
>
|
||||
<!-- Case for previous and conversation conversation card -->
|
||||
<template v-if="showMessageType && multiLine">
|
||||
<Icon
|
||||
v-if="isMessagePrivate"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="messageByAgent"
|
||||
icon="i-lucide-undo-2"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
icon="i-lucide-info"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="message.content && isMessageSticker"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
|
||||
>
|
||||
<Icon icon="i-lucide-image" class="size-3.5" />
|
||||
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
|
||||
</span>
|
||||
|
||||
<template v-else-if="message.content">
|
||||
{{ parsedLastMessage }}
|
||||
</template>
|
||||
|
||||
<span
|
||||
v-else-if="message.attachments"
|
||||
class="inline-block align-middle truncate"
|
||||
>
|
||||
<Icon
|
||||
v-if="attachmentIcon && showMessageType"
|
||||
:icon="attachmentIcon"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<span class="inline-block align-middle">
|
||||
{{ $t(attachmentMessageContent) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<template v-else>
|
||||
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
count: { type: Number, required: true },
|
||||
alignBottom: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const displayCount = computed(() =>
|
||||
props.count > 9 ? t('CHAT_LIST.UNREAD_COUNT_OVERFLOW') : props.count
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="count > 0"
|
||||
class="bg-n-blue-9 rounded-full h-4 min-w-4 max-w-5 px-1 w-fit font-medium text-xxs leading-3 text-white inline-grid place-items-center flex-shrink-0"
|
||||
:class="{
|
||||
'mb-0.5': alignBottom,
|
||||
}"
|
||||
>
|
||||
{{ displayCount }}
|
||||
</span>
|
||||
<span v-else />
|
||||
</template>
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
VOICE_CALL_STATUS,
|
||||
VOICE_CALL_DIRECTION,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: String, default: '' },
|
||||
direction: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const LABEL_KEYS = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||
};
|
||||
|
||||
const isOutbound = computed(
|
||||
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
|
||||
if (props.status === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const statusColor = computed(
|
||||
() => COLOR_MAP[props.status] || 'text-n-slate-11'
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-1 min-w-0 text-sm">
|
||||
<Icon class="size-3.5" :icon="iconName" :class="statusColor" />
|
||||
<span class="truncate text-body-main" :class="statusColor">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
|
||||
defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
|
||||
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
|
||||
<span class="truncate text-label-small text-n-slate-11">
|
||||
{{ inbox.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,10 +20,10 @@ const showCopilotTab = computed(() =>
|
||||
|
||||
const { uiSettings } = useUISettings();
|
||||
const isContactSidebarOpen = computed(
|
||||
() => uiSettings.value.is_contact_sidebar_open
|
||||
() => uiSettings.value.is_contact_sidebar_open || false
|
||||
);
|
||||
const isCopilotPanelOpen = computed(
|
||||
() => uiSettings.value.is_copilot_panel_open
|
||||
() => uiSettings.value.is_copilot_panel_open || false
|
||||
);
|
||||
|
||||
const toggleConversationSidebarToggle = () => {
|
||||
@@ -35,7 +35,7 @@ const toggleConversationSidebarToggle = () => {
|
||||
|
||||
const handleConversationSidebarToggle = () => {
|
||||
updateUISettings({
|
||||
is_contact_sidebar_open: true,
|
||||
is_contact_sidebar_open: !isContactSidebarOpen.value,
|
||||
is_copilot_panel_open: false,
|
||||
});
|
||||
};
|
||||
@@ -43,7 +43,7 @@ const handleConversationSidebarToggle = () => {
|
||||
const handleCopilotSidebarToggle = () => {
|
||||
updateUISettings({
|
||||
is_contact_sidebar_open: false,
|
||||
is_copilot_panel_open: true,
|
||||
is_copilot_panel_open: !isCopilotPanelOpen.value,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
showExtendedInfo: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
const timer = ref(null);
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
type: null,
|
||||
icon: null,
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||
const slaEvents = computed(() => props.chat?.sla_events);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const conversation = computed(() => useCamelCase(props.chat, { deep: true }));
|
||||
|
||||
const showSlaPopoverCard = computed(
|
||||
() => props.showExtendedInfo && slaEvents.value?.length > 0
|
||||
);
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value || {},
|
||||
chat: conversation.value,
|
||||
});
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}, REFRESH_INTERVAL);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.chat, updateSlaStatus);
|
||||
|
||||
defineExpose({
|
||||
hasSlaThreshold,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="hasSlaThreshold"
|
||||
v-bind="$attrs"
|
||||
class="relative flex items-center cursor-pointer min-w-fit group"
|
||||
>
|
||||
<Label
|
||||
:label="slaStatus.threshold"
|
||||
:color="isSlaMissed ? 'ruby' : 'amber'"
|
||||
compact
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
|
||||
</template>
|
||||
</Label>
|
||||
<SLAPopoverCard
|
||||
v-if="showSlaPopoverCard"
|
||||
:sla-missed-events="slaEvents"
|
||||
class="start-0 md:start-auto md:end-0 top-7 hidden group-hover:flex"
|
||||
/>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import { format, fromUnixTime } from 'date-fns';
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const formatDate = timestamp =>
|
||||
format(fromUnixTime(timestamp), 'MMM dd, yyyy, hh:mm a');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between w-full">
|
||||
<span
|
||||
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-n-slate-11"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<span
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="text-sm font-normal text-n-slate-12 text-right tabular-nums"
|
||||
>
|
||||
{{ formatDate(item.created_at) }}
|
||||
</span>
|
||||
<slot name="showMore" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import SLAEventItem from './SLAEventItem.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
slaMissedEvents: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { SLA_MISS_TYPES } = wootConstants;
|
||||
|
||||
const shouldShowAllNrts = ref(false);
|
||||
|
||||
const frtMisses = computed(() =>
|
||||
props.slaMissedEvents.filter(
|
||||
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.FRT
|
||||
)
|
||||
);
|
||||
const nrtMisses = computed(() => {
|
||||
const missedEvents = props.slaMissedEvents.filter(
|
||||
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.NRT
|
||||
);
|
||||
return shouldShowAllNrts.value ? missedEvents : missedEvents.slice(0, 6);
|
||||
});
|
||||
const rtMisses = computed(() =>
|
||||
props.slaMissedEvents.filter(
|
||||
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.RT
|
||||
)
|
||||
);
|
||||
|
||||
const shouldShowMoreNRTButton = computed(() => nrtMisses.value.length > 6);
|
||||
const toggleShowAllNRT = () => {
|
||||
shouldShowAllNrts.value = !shouldShowAllNrts.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute flex flex-col items-start border-n-strong bg-n-solid-3 w-96 backdrop-blur-[100px] px-6 py-5 z-50 shadow rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('SLA.EVENTS.TITLE') }}
|
||||
</span>
|
||||
<SLAEventItem
|
||||
v-if="frtMisses.length"
|
||||
:label="$t('SLA.EVENTS.FRT')"
|
||||
:items="frtMisses"
|
||||
/>
|
||||
<SLAEventItem
|
||||
v-if="nrtMisses.length"
|
||||
:label="$t('SLA.EVENTS.NRT')"
|
||||
:items="nrtMisses"
|
||||
>
|
||||
<template #showMore>
|
||||
<div
|
||||
v-if="shouldShowMoreNRTButton"
|
||||
class="flex flex-col items-end w-full"
|
||||
>
|
||||
<Button
|
||||
link
|
||||
xs
|
||||
slate
|
||||
class="hover:!no-underline"
|
||||
:icon="!shouldShowAllNrts ? 'i-lucide-plus' : ''"
|
||||
:label="
|
||||
shouldShowAllNrts
|
||||
? $t('SLA.EVENTS.HIDE', { count: nrtMisses.length })
|
||||
: $t('SLA.EVENTS.SHOW_MORE', { count: nrtMisses.length })
|
||||
"
|
||||
@click="toggleShowAllNRT"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SLAEventItem>
|
||||
<SLAEventItem
|
||||
v-if="rtMisses.length"
|
||||
:label="$t('SLA.EVENTS.RT')"
|
||||
:items="rtMisses"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -11,7 +11,13 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
validator: value => {
|
||||
return value.every(item => item.action && item.value && item.label);
|
||||
return value.every(
|
||||
item =>
|
||||
item.action &&
|
||||
item.value !== undefined &&
|
||||
item.value !== null &&
|
||||
item.label
|
||||
);
|
||||
},
|
||||
},
|
||||
menuSections: {
|
||||
@@ -22,6 +28,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
roundedThumbnail: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -42,9 +52,17 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emptyStateMessage: {
|
||||
type: String,
|
||||
default: 'DROPDOWN_MENU.EMPTY_STATE',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'search']);
|
||||
const emit = defineEmits(['action', 'search', 'empty']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -96,9 +114,13 @@ const filteredMenuSections = computed(() => {
|
||||
});
|
||||
|
||||
const handleSearchInput = event => {
|
||||
if (props.disableLocalFiltering) {
|
||||
emit('search', event.target.value);
|
||||
}
|
||||
emit('search', event.target.value);
|
||||
|
||||
const isEmpty = hasSections.value
|
||||
? filteredMenuSections.value.length === 0
|
||||
: filteredMenuItems.value.length === 0;
|
||||
|
||||
if (isEmpty) emit('empty');
|
||||
};
|
||||
|
||||
const handleAction = item => {
|
||||
@@ -190,22 +212,27 @@ onMounted(() => {
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
:rounded-full="roundedThumbnail"
|
||||
/>
|
||||
</slot>
|
||||
<slot name="icon" :item="item">
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
</slot>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<slot name="label" :item="item">
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm font-420 truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</slot>
|
||||
<slot name="trailing-icon" :item="item" />
|
||||
</button>
|
||||
<div
|
||||
v-if="sectionIndex < filteredMenuSections.length - 1"
|
||||
@@ -214,6 +241,9 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-2">
|
||||
<Spinner :size="24" />
|
||||
</div>
|
||||
<button
|
||||
v-for="(item, index) in filteredMenuItems"
|
||||
:key="index"
|
||||
@@ -233,22 +263,27 @@ onMounted(() => {
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
:rounded-full="roundedThumbnail"
|
||||
/>
|
||||
</slot>
|
||||
<slot name="icon" :item="item">
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
</slot>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<slot name="label" :item="item">
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm font-420 truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</slot>
|
||||
<slot name="trailing-icon" :item="item" />
|
||||
</button>
|
||||
</template>
|
||||
<div
|
||||
@@ -258,7 +293,9 @@ onMounted(() => {
|
||||
{{
|
||||
isSearching
|
||||
? t('DROPDOWN_MENU.SEARCHING')
|
||||
: t('DROPDOWN_MENU.EMPTY_STATE')
|
||||
: searchQuery
|
||||
? t('DROPDOWN_MENU.EMPTY_STATE')
|
||||
: t(emptyStateMessage)
|
||||
}}
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
|
||||
@@ -39,38 +39,45 @@ const formatFilterValue = value => {
|
||||
|
||||
// Case 2: array → map each item, use name if present, else the item itself
|
||||
if (Array.isArray(value)) {
|
||||
// Empty array
|
||||
if (value.length === 0) return '';
|
||||
return value.map(item => item?.name ?? item).join(', ');
|
||||
}
|
||||
|
||||
// Case 3: object with a "name" property → return name
|
||||
// Case 4: primitive (string, number, etc.) → return as is
|
||||
// Case 3: empty object
|
||||
if (typeof value === 'object' && Object.keys(value).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Case 4: object with a "name" property → return name
|
||||
// Case 5: primitive (string, number, etc.) → return as is
|
||||
return value?.name ?? value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center w-full gap-2 mx-auto">
|
||||
<div class="flex flex-wrap items-center w-full gap-x-1.5 gap-y-2 mx-auto">
|
||||
<template v-for="(filter, index) in appliedFilters" :key="index">
|
||||
<div
|
||||
v-if="index < maxVisibleFilters"
|
||||
class="inline-flex items-center gap-2 h-7"
|
||||
class="inline-flex items-center gap-1.5 h-7"
|
||||
>
|
||||
<div
|
||||
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer"
|
||||
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer bg-n-button-color"
|
||||
@click="emit('openFilter')"
|
||||
>
|
||||
<span
|
||||
class="lowercase whitespace-nowrap first-letter:capitalize text-n-slate-12"
|
||||
class="lowercase whitespace-nowrap font-440 first-letter:capitalize text-n-slate-12"
|
||||
>
|
||||
{{ replaceUnderscoreWithSpace(filter.attributeKey) }}
|
||||
</span>
|
||||
<span class="px-1 text-xs text-n-slate-10 whitespace-nowrap">
|
||||
<span class="px-1 text-xs text-n-slate-11 font-440 whitespace-nowrap">
|
||||
{{ formatOperatorLabel(filter.filterOperator) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="filter.values"
|
||||
v-if="formatFilterValue(filter.values)"
|
||||
:title="formatFilterValue(filter.values)"
|
||||
class="lowercase truncate text-n-slate-12"
|
||||
class="lowercase truncate text-n-slate-12 font-440"
|
||||
:class="{
|
||||
'first-letter:capitalize': shouldCapitalizeFirstLetter(
|
||||
filter.attributeKey
|
||||
@@ -86,7 +93,7 @@ const formatFilterValue = value => {
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="content-center h-full px-1 text-xs font-medium uppercase rounded-lg text-n-slate-10"
|
||||
class="content-center h-full px-1 text-xs font-440 uppercase rounded-lg text-n-slate-11"
|
||||
>
|
||||
{{ filter.queryOperator }}
|
||||
</span>
|
||||
@@ -95,7 +102,7 @@ const formatFilterValue = value => {
|
||||
</template>
|
||||
<div
|
||||
v-if="appliedFilters.length > maxVisibleFilters"
|
||||
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer"
|
||||
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer font-440"
|
||||
@click="emit('openFilter')"
|
||||
>
|
||||
{{ moreFiltersLabel }}
|
||||
@@ -105,9 +112,10 @@ const formatFilterValue = value => {
|
||||
v-if="showClearButton"
|
||||
:label="clearButtonLabel"
|
||||
size="xs"
|
||||
class="!px-1"
|
||||
variant="ghost"
|
||||
class="!px-1 hover:!no-underline"
|
||||
link
|
||||
@click="emit('clearFilters')"
|
||||
/>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, onBeforeUnmount, computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useBreakpoints } from '@vueuse/core';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useConversationFilterContext } from './provider.js';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
@@ -42,6 +44,13 @@ const DEFAULT_FILTER = {
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const breakpoints = useBreakpoints({
|
||||
mobile: wootConstants.SMALL_SCREEN_BREAKPOINT,
|
||||
});
|
||||
|
||||
const isSmallScreen = breakpoints.smaller('mobile');
|
||||
const viewInModal = computed(() => isSmallScreen.value);
|
||||
|
||||
const resetFilter = () => {
|
||||
filters.value = [{ ...DEFAULT_FILTER }];
|
||||
};
|
||||
@@ -104,69 +113,80 @@ const outsideClickHandler = [
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="outsideClickHandler"
|
||||
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
|
||||
:class="{
|
||||
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start justify-center inset-0 px-2 pt-14 pb-8 overflow-y-auto overflow-x-hidden':
|
||||
viewInModal,
|
||||
}"
|
||||
@click.self="viewInModal && emit('close')"
|
||||
>
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ filterModalHeaderTitle }}
|
||||
</h3>
|
||||
<div v-if="props.isFolderView">
|
||||
<div class="border-b border-n-weak pb-6">
|
||||
<Input
|
||||
v-model="folderNameLocal"
|
||||
:label="t('FILTER.FOLDER_LABEL')"
|
||||
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
<div
|
||||
v-on-click-outside="viewInModal ? [] : outsideClickHandler"
|
||||
class="z-40 max-w-3xl xl:w-[750px] w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl py-6 grid gap-6"
|
||||
:class="{ 'overflow-x-auto': viewInModal }"
|
||||
>
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12 px-6">
|
||||
{{ filterModalHeaderTitle }}
|
||||
</h3>
|
||||
<div v-if="props.isFolderView" class="px-6">
|
||||
<div class="border-b border-n-strong pb-6">
|
||||
<Input
|
||||
v-model="folderNameLocal"
|
||||
:label="t('FILTER.FOLDER_LABEL')"
|
||||
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="grid gap-4 list-none">
|
||||
<template v-for="(filter, index) in filters" :key="filter.id">
|
||||
<ConditionRow
|
||||
v-if="index === 0"
|
||||
ref="conditionsRef"
|
||||
:key="`filter-${filter.attributeKey}-0`"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
:key="`filter-${filter.attributeKey}-${index}`"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:query-operator="filters[index - 1].queryOperator"
|
||||
v-model:values="filter.values"
|
||||
show-query-operator
|
||||
:filter-types="filterTypes"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="flex gap-2 justify-between">
|
||||
<Button sm ghost blue @click="addFilter">
|
||||
{{ $t('FILTER.ADD_NEW_FILTER') }}
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button sm faded slate @click="resetFilter">
|
||||
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isFolderView"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:disabled="!folderNameLocal"
|
||||
@click="updateSavedCustomViews"
|
||||
>
|
||||
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
<Button v-else sm solid blue @click="validateAndSubmit">
|
||||
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
|
||||
<ul class="grid gap-4 list-none px-6">
|
||||
<template v-for="(filter, index) in filters" :key="filter.id">
|
||||
<ConditionRow
|
||||
v-if="index === 0"
|
||||
ref="conditionsRef"
|
||||
:key="`filter-${filter.attributeKey}-0`"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
:key="`filter-${filter.attributeKey}-${index}`"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:query-operator="filters[index - 1].queryOperator"
|
||||
v-model:values="filter.values"
|
||||
show-query-operator
|
||||
:filter-types="filterTypes"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
<div
|
||||
class="flex gap-2 justify-between ltr:pl-3 ltr:pr-6 rtl:pr-3 rtl:pl-6"
|
||||
>
|
||||
<Button sm ghost blue class="px-0" @click="addFilter">
|
||||
{{ $t('FILTER.ADD_NEW_FILTER') }}
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button sm solid slate @click="resetFilter">
|
||||
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isFolderView"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:disabled="!folderNameLocal"
|
||||
@click="updateSavedCustomViews"
|
||||
>
|
||||
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
<Button v-else sm solid blue @click="validateAndSubmit">
|
||||
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,8 +108,10 @@ export default {
|
||||
<NextInput
|
||||
v-model="name"
|
||||
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
|
||||
:message="v$.name.$error && $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE')"
|
||||
:message-type="v$.name.$error && 'error'"
|
||||
:message="
|
||||
v$.name.$error ? $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') : ''
|
||||
"
|
||||
:message-type="v$.name.$error ? 'error' : 'info'"
|
||||
@blur="v$.name.$touch"
|
||||
/>
|
||||
<div class="flex flex-row justify-end w-full gap-2">
|
||||
|
||||
@@ -95,7 +95,7 @@ const toggleOption = option => {
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
v-if="hasItems"
|
||||
class="bg-n-alpha-2 py-2 rounded-lg h-8 flex items-center px-0"
|
||||
class="bg-n-button-color outline outline-1 outline-n-container py-2 rounded-lg h-8 flex items-center px-0"
|
||||
@click="toggle"
|
||||
>
|
||||
<div
|
||||
@@ -119,11 +119,13 @@ const toggleOption = option => {
|
||||
<Icon icon="i-lucide-plus" />
|
||||
</div>
|
||||
</button>
|
||||
<Button v-else sm slate faded @click="toggle">
|
||||
<Button v-else sm slate solid @click="toggle">
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
|
||||
<Icon icon="i-lucide-plus" class="text-n-slate-11 flex-shrink-0" />
|
||||
</template>
|
||||
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
|
||||
<span class="text-n-slate-11 truncate">{{
|
||||
t('COMBOBOX.PLACEHOLDER')
|
||||
}}</span>
|
||||
</Button>
|
||||
</template>
|
||||
<DropdownBody class="top-0 min-w-48 z-50" strong>
|
||||
|
||||
Reference in New Issue
Block a user