@escalated-dev/escalated 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +31 -0
- package/package.json +12 -6
- package/src/components/ActiveChatsPanel.vue +246 -0
- package/src/components/ActivityTimeline.stories.js +55 -55
- package/src/components/ActivityTimeline.vue +83 -83
- package/src/components/AdminDashboard.stories.js +1423 -1095
- package/src/components/AgentLoadIndicator.stories.js +37 -37
- package/src/components/AgentLoadIndicator.vue +49 -49
- package/src/components/AgentPerformanceCard.vue +44 -44
- package/src/components/ArticleEditor.vue +82 -82
- package/src/components/AssigneeSelect.vue +37 -37
- package/src/components/AttachmentList.vue +13 -1
- package/src/components/AuditLogEntry.vue +95 -95
- package/src/components/BulkActionBar.vue +166 -166
- package/src/components/CategoryTree.vue +89 -89
- package/src/components/ChartWidget.vue +46 -46
- package/src/components/ChatActionBar.vue +226 -0
- package/src/components/ChatBubble.vue +162 -0
- package/src/components/ChatComposer.vue +308 -0
- package/src/components/ChatQueue.vue +222 -0
- package/src/components/ChatThread.vue +197 -0
- package/src/components/CollisionWarning.stories.js +30 -30
- package/src/components/CollisionWarning.vue +68 -68
- package/src/components/ConditionalFieldRules.vue +135 -135
- package/src/components/ContextPanel.vue +66 -66
- package/src/components/ContextPanelSection.vue +65 -65
- package/src/components/CustomFieldRenderer.vue +159 -159
- package/src/components/EscalatedLayout.vue +29 -6
- package/src/components/FileDropzone.vue +57 -57
- package/src/components/FollowButton.stories.js +30 -30
- package/src/components/FollowButton.vue +88 -88
- package/src/components/KeyboardShortcutHelp.vue +213 -213
- package/src/components/KnowledgePanel.vue +122 -122
- package/src/components/KpiCard.stories.js +32 -32
- package/src/components/KpiCard.vue +88 -88
- package/src/components/MacroDropdown.vue +150 -150
- package/src/components/MentionDropdown.vue +154 -0
- package/src/components/MetricComparison.vue +89 -0
- package/src/components/PercentileChart.vue +67 -0
- package/src/components/PeriodSelector.vue +98 -0
- package/src/components/PermissionMatrix.vue +107 -107
- package/src/components/PinnedNotes.vue +129 -129
- package/src/components/PluginSlot.vue +68 -62
- package/src/components/PresenceIndicator.vue +46 -2
- package/src/components/PriorityBadge.stories.js +33 -33
- package/src/components/PriorityBadge.vue +46 -46
- package/src/components/ReplyComposer.vue +81 -18
- package/src/components/ReplyThread.vue +40 -2
- package/src/components/ReportChart.vue +395 -0
- package/src/components/ReportExportButton.vue +108 -0
- package/src/components/SatisfactionRating.vue +180 -180
- package/src/components/SaveViewDialog.vue +172 -0
- package/src/components/SavedViewSidebar.vue +208 -0
- package/src/components/ScheduleEditor.vue +104 -104
- package/src/components/SideConversation.vue +165 -165
- package/src/components/SideConversationList.vue +147 -147
- package/src/components/SkillTagManager.vue +79 -79
- package/src/components/SlaComplianceChart.vue +46 -53
- package/src/components/SlaTimer.stories.js +35 -35
- package/src/components/SlaTimer.vue +62 -62
- package/src/components/SnoozeButton.vue +247 -0
- package/src/components/StatsCard.stories.js +24 -24
- package/src/components/StatsCard.vue +58 -58
- package/src/components/StatusBadge.stories.js +53 -53
- package/src/components/StatusBadge.vue +78 -78
- package/src/components/TagSelect.vue +84 -84
- package/src/components/TicketFilters.vue +270 -225
- package/src/components/TicketLinkPanel.vue +193 -193
- package/src/components/TicketList.vue +655 -594
- package/src/components/TicketMergeDialog.vue +157 -157
- package/src/components/TicketSplitDialog.vue +98 -0
- package/src/components/TotpInput.stories.js +9 -9
- package/src/components/TotpInput.vue +81 -81
- package/src/components/TwoFactorSetup.vue +113 -113
- package/src/components/VariableTokenInput.vue +111 -0
- package/src/components/WorkflowActionConfig.vue +287 -0
- package/src/components/WorkflowActionList.vue +350 -0
- package/src/components/WorkflowConditionBuilder.vue +265 -0
- package/src/components/WorkflowTriggerSelector.vue +125 -0
- package/src/composables/useChat.js +187 -0
- package/src/composables/useI18n.js +80 -80
- package/src/composables/useKeyboardShortcuts.js +46 -46
- package/src/composables/useMentions.js +134 -0
- package/src/composables/usePluginExtensions.js +159 -159
- package/src/composables/usePluginHooks.js +65 -65
- package/src/composables/useRealtime.js +182 -0
- package/src/defineEscalatedPlugin.js +76 -76
- package/src/index.js +84 -72
- package/src/locales/ar.json +727 -0
- package/src/locales/de.json +678 -430
- package/src/locales/en.json +717 -455
- package/src/locales/es.json +679 -430
- package/src/locales/fr.json +680 -431
- package/src/locales/index.js +12 -2
- package/src/locales/it.json +727 -0
- package/src/locales/ja.json +727 -0
- package/src/locales/ko.json +727 -0
- package/src/locales/nl.json +727 -0
- package/src/locales/pl.json +727 -0
- package/src/locales/pt-BR.json +727 -0
- package/src/locales/ru.json +727 -0
- package/src/locales/tr.json +727 -0
- package/src/locales/zh-CN.json +727 -0
- package/src/pages/Admin/ApiTokens/Index.vue +381 -381
- package/src/pages/Admin/AuditLog/Index.vue +154 -154
- package/src/pages/Admin/BusinessHours/Form.vue +172 -172
- package/src/pages/Admin/BusinessHours/Index.vue +113 -113
- package/src/pages/Admin/CannedResponses/Index.vue +127 -127
- package/src/pages/Admin/Capacity/Index.vue +134 -134
- package/src/pages/Admin/CustomFields/Form.vue +195 -195
- package/src/pages/Admin/CustomFields/Index.vue +134 -134
- package/src/pages/Admin/CustomObjects/Form.vue +202 -202
- package/src/pages/Admin/CustomObjects/Index.vue +127 -127
- package/src/pages/Admin/CustomObjects/Records.vue +228 -228
- package/src/pages/Admin/Departments/Form.vue +75 -75
- package/src/pages/Admin/Departments/Index.vue +110 -109
- package/src/pages/Admin/EscalationRules/Form.vue +157 -157
- package/src/pages/Admin/EscalationRules/Index.vue +178 -178
- package/src/pages/Admin/Import/Connect.vue +0 -1
- package/src/pages/Admin/Import/Index.vue +1 -1
- package/src/pages/Admin/Import/Progress.vue +1 -1
- package/src/pages/Admin/KnowledgeBase/Articles/Form.vue +142 -142
- package/src/pages/Admin/KnowledgeBase/Articles/Index.vue +195 -195
- package/src/pages/Admin/KnowledgeBase/Categories/Index.vue +193 -193
- package/src/pages/Admin/Macros/Index.vue +448 -448
- package/src/pages/Admin/Plugins/Index.vue +327 -327
- package/src/pages/Admin/Reports/AgentDetail.vue +132 -0
- package/src/pages/Admin/Reports/AgentMetrics.vue +115 -115
- package/src/pages/Admin/Reports/AgentRanking.vue +246 -0
- package/src/pages/Admin/Reports/Cohorts.vue +180 -0
- package/src/pages/Admin/Reports/Comparison.vue +141 -0
- package/src/pages/Admin/Reports/CsatReport.vue +127 -127
- package/src/pages/Admin/Reports/Dashboard.vue +36 -21
- package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -0
- package/src/pages/Admin/Reports/ResponseTimes.vue +164 -0
- package/src/pages/Admin/Reports/SlaReport.vue +162 -166
- package/src/pages/Admin/Reports/SlaTrends.vue +183 -0
- package/src/pages/Admin/Reports.vue +206 -206
- package/src/pages/Admin/Roles/Form.vue +71 -71
- package/src/pages/Admin/Roles/Index.vue +122 -122
- package/src/pages/Admin/Settings/CsatSettings.vue +165 -165
- package/src/pages/Admin/Settings/DataRetention.vue +182 -182
- package/src/pages/Admin/Settings/EmailSettings.vue +243 -243
- package/src/pages/Admin/Settings/Sandbox.vue +135 -135
- package/src/pages/Admin/Settings/SsoSettings.vue +218 -218
- package/src/pages/Admin/Settings/TwoFactor.vue +110 -110
- package/src/pages/Admin/Settings.vue +1008 -468
- package/src/pages/Admin/Skills/Form.vue +53 -53
- package/src/pages/Admin/Skills/Index.vue +94 -94
- package/src/pages/Admin/SlaPolicies/Form.vue +118 -118
- package/src/pages/Admin/SlaPolicies/Index.vue +121 -120
- package/src/pages/Admin/Statuses/Form.vue +114 -114
- package/src/pages/Admin/Statuses/Index.vue +153 -153
- package/src/pages/Admin/Tags/Index.vue +191 -191
- package/src/pages/Admin/Tickets/Index.vue +126 -91
- package/src/pages/Admin/Tickets/Show.vue +29 -0
- package/src/pages/Admin/Webhooks/DeliveryLog.vue +120 -120
- package/src/pages/Admin/Webhooks/Form.vue +110 -110
- package/src/pages/Admin/Webhooks/Index.vue +136 -136
- package/src/pages/Admin/Workflows/Builder.vue +207 -0
- package/src/pages/Admin/Workflows/Index.vue +235 -0
- package/src/pages/Admin/Workflows/Logs.vue +286 -0
- package/src/pages/Agent/TicketIndex.vue +112 -88
- package/src/pages/Agent/TicketShow.vue +192 -60
- package/src/pages/Customer/Create.vue +1 -1
- package/src/pages/Customer/Index.vue +31 -31
- package/src/pages/Customer/KnowledgeBase/Article.vue +4 -2
- package/src/pages/Customer/KnowledgeBase/Index.vue +151 -151
- package/src/pages/Customer/Show.vue +69 -63
- package/src/pages/Guest/Create.vue +40 -15
- package/src/pages/Guest/Show.vue +124 -93
- package/src/plugin.js +25 -0
- package/src/utils/mentionHighlight.js +24 -0
- package/src/utils/sanitizeHtml.js +78 -64
- package/src/widget/EscalatedWidget.vue +804 -0
- package/src/widget/index.js +254 -0
- package/src/pages/Admin/Automations/Form.vue +0 -184
- package/src/pages/Admin/Automations/Index.vue +0 -129
|
@@ -1,62 +1,68 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import { defineAsyncComponent, h } from 'vue';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
slot: {
|
|
6
|
-
type: String,
|
|
7
|
-
required: true,
|
|
8
|
-
},
|
|
9
|
-
components: {
|
|
10
|
-
type: Array,
|
|
11
|
-
default: () => [],
|
|
12
|
-
},
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
// Error fallback component rendered when a plugin component fails to load
|
|
16
|
-
const ErrorFallback = {
|
|
17
|
-
props: ['error', 'plugin', 'component'],
|
|
18
|
-
render() {
|
|
19
|
-
return h(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
1
|
+
<script setup>
|
|
2
|
+
import { defineAsyncComponent, h } from 'vue';
|
|
3
|
+
|
|
4
|
+
defineProps({
|
|
5
|
+
slot: {
|
|
6
|
+
type: String,
|
|
7
|
+
required: true,
|
|
8
|
+
},
|
|
9
|
+
components: {
|
|
10
|
+
type: Array,
|
|
11
|
+
default: () => [],
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Error fallback component rendered when a plugin component fails to load
|
|
16
|
+
const ErrorFallback = {
|
|
17
|
+
props: ['error', 'plugin', 'component'],
|
|
18
|
+
render() {
|
|
19
|
+
return h(
|
|
20
|
+
'div',
|
|
21
|
+
{
|
|
22
|
+
class: 'rounded-lg border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-xs text-rose-400',
|
|
23
|
+
},
|
|
24
|
+
`Plugin "${this.plugin}" failed to load component "${this.component}"`,
|
|
25
|
+
);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Loading placeholder component
|
|
30
|
+
const LoadingFallback = {
|
|
31
|
+
render() {
|
|
32
|
+
return h(
|
|
33
|
+
'div',
|
|
34
|
+
{
|
|
35
|
+
class: 'animate-pulse rounded-lg bg-white/[0.03] px-3 py-2',
|
|
36
|
+
},
|
|
37
|
+
[h('div', { class: 'h-4 w-24 rounded bg-white/[0.06]' })],
|
|
38
|
+
);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Load plugin components dynamically from host app's plugins directory
|
|
43
|
+
const loadPluginComponent = (plugin, component) => {
|
|
44
|
+
return defineAsyncComponent({
|
|
45
|
+
loader: () => import(`../../../../plugins/escalated/${plugin}/resources/js/Components/${component}.vue`),
|
|
46
|
+
loadingComponent: LoadingFallback,
|
|
47
|
+
errorComponent: {
|
|
48
|
+
props: ['error'],
|
|
49
|
+
render() {
|
|
50
|
+
return h(ErrorFallback, { error: this.error, plugin, component });
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
delay: 100,
|
|
54
|
+
timeout: 10000,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<template v-if="components?.length > 0">
|
|
61
|
+
<component
|
|
62
|
+
:is="loadPluginComponent(pluginComp.plugin, pluginComp.component)"
|
|
63
|
+
v-for="(pluginComp, index) in components"
|
|
64
|
+
:key="`${slot}-${pluginComp.plugin}-${pluginComp.component}-${index}`"
|
|
65
|
+
v-bind="pluginComp.data || {}"
|
|
66
|
+
/>
|
|
67
|
+
</template>
|
|
68
|
+
</template>
|
|
@@ -3,12 +3,14 @@ import { ref, computed, inject, onMounted, onUnmounted } from 'vue';
|
|
|
3
3
|
|
|
4
4
|
const props = defineProps({
|
|
5
5
|
ticketReference: { type: String, required: true },
|
|
6
|
+
ticketId: { type: [Number, String], default: null },
|
|
6
7
|
routePrefix: { type: String, required: true },
|
|
7
8
|
pollInterval: { type: Number, default: 30000 },
|
|
8
9
|
showLabel: { type: Boolean, default: false },
|
|
9
10
|
});
|
|
10
11
|
|
|
11
12
|
const effectiveInterval = computed(() => Math.max(props.pollInterval, 5000));
|
|
13
|
+
const echoAvailable = typeof window !== 'undefined' && !!window.Echo;
|
|
12
14
|
|
|
13
15
|
const escDark = inject(
|
|
14
16
|
'esc-dark',
|
|
@@ -17,6 +19,7 @@ const escDark = inject(
|
|
|
17
19
|
const viewers = ref([]);
|
|
18
20
|
const hovering = ref(false);
|
|
19
21
|
let intervalId = null;
|
|
22
|
+
let presenceChannel = null;
|
|
20
23
|
|
|
21
24
|
async function fetchPresence() {
|
|
22
25
|
try {
|
|
@@ -38,6 +41,35 @@ async function fetchPresence() {
|
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Use Echo presence channel for real-time viewer tracking when available.
|
|
46
|
+
* Falls back to polling when Echo is not configured.
|
|
47
|
+
*/
|
|
48
|
+
function initPresenceChannel() {
|
|
49
|
+
if (!echoAvailable || !props.ticketId) return false;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
presenceChannel = window.Echo.join(`escalated.tickets.${props.ticketId}`);
|
|
53
|
+
|
|
54
|
+
presenceChannel
|
|
55
|
+
.here((users) => {
|
|
56
|
+
viewers.value = users;
|
|
57
|
+
})
|
|
58
|
+
.joining((user) => {
|
|
59
|
+
if (!viewers.value.find((v) => v.id === user.id)) {
|
|
60
|
+
viewers.value = [...viewers.value, user];
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.leaving((user) => {
|
|
64
|
+
viewers.value = viewers.value.filter((v) => v.id !== user.id);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
41
73
|
const tooltipText = computed(() => {
|
|
42
74
|
if (!viewers.value.length) return '';
|
|
43
75
|
const names = viewers.value.map((v) => v.name);
|
|
@@ -68,8 +100,12 @@ function getColor(index) {
|
|
|
68
100
|
}
|
|
69
101
|
|
|
70
102
|
onMounted(() => {
|
|
71
|
-
|
|
72
|
-
|
|
103
|
+
// Prefer presence channels via Echo; fall back to polling
|
|
104
|
+
const usingEcho = initPresenceChannel();
|
|
105
|
+
if (!usingEcho) {
|
|
106
|
+
fetchPresence();
|
|
107
|
+
intervalId = setInterval(fetchPresence, effectiveInterval.value);
|
|
108
|
+
}
|
|
73
109
|
});
|
|
74
110
|
|
|
75
111
|
onUnmounted(() => {
|
|
@@ -77,6 +113,14 @@ onUnmounted(() => {
|
|
|
77
113
|
clearInterval(intervalId);
|
|
78
114
|
intervalId = null;
|
|
79
115
|
}
|
|
116
|
+
if (presenceChannel && props.ticketId) {
|
|
117
|
+
try {
|
|
118
|
+
window.Echo.leave(`escalated.tickets.${props.ticketId}`);
|
|
119
|
+
} catch {
|
|
120
|
+
// ignore
|
|
121
|
+
}
|
|
122
|
+
presenceChannel = null;
|
|
123
|
+
}
|
|
80
124
|
});
|
|
81
125
|
</script>
|
|
82
126
|
|
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
import PriorityBadge from './PriorityBadge.vue';
|
|
2
|
-
|
|
3
|
-
export default {
|
|
4
|
-
title: 'Components/PriorityBadge',
|
|
5
|
-
component: PriorityBadge,
|
|
6
|
-
argTypes: {
|
|
7
|
-
priority: {
|
|
8
|
-
control: 'select',
|
|
9
|
-
options: ['low', 'medium', 'high', 'urgent', 'critical'],
|
|
10
|
-
},
|
|
11
|
-
},
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export const Low = { args: { priority: 'low' } };
|
|
15
|
-
export const Medium = { args: { priority: 'medium' } };
|
|
16
|
-
export const High = { args: { priority: 'high' } };
|
|
17
|
-
export const Urgent = { args: { priority: 'urgent' } };
|
|
18
|
-
export const Critical = { args: { priority: 'critical' } };
|
|
19
|
-
|
|
20
|
-
export const AllPriorities = {
|
|
21
|
-
render: () => ({
|
|
22
|
-
components: { PriorityBadge },
|
|
23
|
-
template: `
|
|
24
|
-
<div class="flex flex-wrap items-center gap-2">
|
|
25
|
-
<PriorityBadge priority="low" />
|
|
26
|
-
<PriorityBadge priority="medium" />
|
|
27
|
-
<PriorityBadge priority="high" />
|
|
28
|
-
<PriorityBadge priority="urgent" />
|
|
29
|
-
<PriorityBadge priority="critical" />
|
|
30
|
-
</div>
|
|
31
|
-
`,
|
|
32
|
-
}),
|
|
33
|
-
};
|
|
1
|
+
import PriorityBadge from './PriorityBadge.vue';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
title: 'Components/PriorityBadge',
|
|
5
|
+
component: PriorityBadge,
|
|
6
|
+
argTypes: {
|
|
7
|
+
priority: {
|
|
8
|
+
control: 'select',
|
|
9
|
+
options: ['low', 'medium', 'high', 'urgent', 'critical'],
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const Low = { args: { priority: 'low' } };
|
|
15
|
+
export const Medium = { args: { priority: 'medium' } };
|
|
16
|
+
export const High = { args: { priority: 'high' } };
|
|
17
|
+
export const Urgent = { args: { priority: 'urgent' } };
|
|
18
|
+
export const Critical = { args: { priority: 'critical' } };
|
|
19
|
+
|
|
20
|
+
export const AllPriorities = {
|
|
21
|
+
render: () => ({
|
|
22
|
+
components: { PriorityBadge },
|
|
23
|
+
template: `
|
|
24
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
25
|
+
<PriorityBadge priority="low" />
|
|
26
|
+
<PriorityBadge priority="medium" />
|
|
27
|
+
<PriorityBadge priority="high" />
|
|
28
|
+
<PriorityBadge priority="urgent" />
|
|
29
|
+
<PriorityBadge priority="critical" />
|
|
30
|
+
</div>
|
|
31
|
+
`,
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import { inject, computed } from 'vue';
|
|
3
|
-
|
|
4
|
-
const props = defineProps({
|
|
5
|
-
priority: { type: String, required: true },
|
|
6
|
-
});
|
|
7
|
-
|
|
8
|
-
const escDark = inject(
|
|
9
|
-
'esc-dark',
|
|
10
|
-
computed(() => false),
|
|
11
|
-
);
|
|
12
|
-
|
|
13
|
-
const darkConfig = {
|
|
14
|
-
low: { label: 'Low', color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' },
|
|
15
|
-
medium: { label: 'Medium', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
|
|
16
|
-
high: { label: 'High', color: 'bg-amber-500/10 text-amber-400 ring-1 ring-amber-500/20' },
|
|
17
|
-
urgent: { label: 'Urgent', color: 'bg-orange-500/10 text-orange-400 ring-1 ring-orange-500/20' },
|
|
18
|
-
critical: { label: 'Critical', color: 'bg-rose-500/10 text-rose-300 ring-1 ring-rose-500/20' },
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const lightConfig = {
|
|
22
|
-
low: { label: 'Low', color: 'bg-gray-100 text-gray-800' },
|
|
23
|
-
medium: { label: 'Medium', color: 'bg-blue-100 text-blue-800' },
|
|
24
|
-
high: { label: 'High', color: 'bg-yellow-100 text-yellow-800' },
|
|
25
|
-
urgent: { label: 'Urgent', color: 'bg-orange-100 text-orange-800' },
|
|
26
|
-
critical: { label: 'Critical', color: 'bg-red-100 text-red-800' },
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const config = computed(() => {
|
|
30
|
-
const map = escDark.value ? darkConfig : lightConfig;
|
|
31
|
-
const fallback = escDark.value
|
|
32
|
-
? { label: props.priority, color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' }
|
|
33
|
-
: { label: props.priority, color: 'bg-gray-100 text-gray-800' };
|
|
34
|
-
return map[props.priority] || fallback;
|
|
35
|
-
});
|
|
36
|
-
</script>
|
|
37
|
-
|
|
38
|
-
<template>
|
|
39
|
-
<span
|
|
40
|
-
role="status"
|
|
41
|
-
:aria-label="'Priority: ' + config.label"
|
|
42
|
-
:class="['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', config.color]"
|
|
43
|
-
>
|
|
44
|
-
{{ config.label }}
|
|
45
|
-
</span>
|
|
46
|
-
</template>
|
|
1
|
+
<script setup>
|
|
2
|
+
import { inject, computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
priority: { type: String, required: true },
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const escDark = inject(
|
|
9
|
+
'esc-dark',
|
|
10
|
+
computed(() => false),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const darkConfig = {
|
|
14
|
+
low: { label: 'Low', color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' },
|
|
15
|
+
medium: { label: 'Medium', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
|
|
16
|
+
high: { label: 'High', color: 'bg-amber-500/10 text-amber-400 ring-1 ring-amber-500/20' },
|
|
17
|
+
urgent: { label: 'Urgent', color: 'bg-orange-500/10 text-orange-400 ring-1 ring-orange-500/20' },
|
|
18
|
+
critical: { label: 'Critical', color: 'bg-rose-500/10 text-rose-300 ring-1 ring-rose-500/20' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const lightConfig = {
|
|
22
|
+
low: { label: 'Low', color: 'bg-gray-100 text-gray-800' },
|
|
23
|
+
medium: { label: 'Medium', color: 'bg-blue-100 text-blue-800' },
|
|
24
|
+
high: { label: 'High', color: 'bg-yellow-100 text-yellow-800' },
|
|
25
|
+
urgent: { label: 'Urgent', color: 'bg-orange-100 text-orange-800' },
|
|
26
|
+
critical: { label: 'Critical', color: 'bg-red-100 text-red-800' },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const config = computed(() => {
|
|
30
|
+
const map = escDark.value ? darkConfig : lightConfig;
|
|
31
|
+
const fallback = escDark.value
|
|
32
|
+
? { label: props.priority, color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' }
|
|
33
|
+
: { label: props.priority, color: 'bg-gray-100 text-gray-800' };
|
|
34
|
+
return map[props.priority] || fallback;
|
|
35
|
+
});
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<span
|
|
40
|
+
role="status"
|
|
41
|
+
:aria-label="'Priority: ' + config.label"
|
|
42
|
+
:class="['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', config.color]"
|
|
43
|
+
>
|
|
44
|
+
{{ config.label }}
|
|
45
|
+
</span>
|
|
46
|
+
</template>
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { ref, computed, inject } from 'vue';
|
|
3
3
|
import { router } from '@inertiajs/vue3';
|
|
4
4
|
import FileDropzone from './FileDropzone.vue';
|
|
5
|
+
import MentionDropdown from './MentionDropdown.vue';
|
|
6
|
+
import { useMentions } from '../composables/useMentions';
|
|
5
7
|
|
|
6
8
|
const props = defineProps({
|
|
7
9
|
action: { type: String, required: true },
|
|
@@ -22,6 +24,35 @@ const body = ref('');
|
|
|
22
24
|
const isNote = ref(false);
|
|
23
25
|
const files = ref([]);
|
|
24
26
|
const submitting = ref(false);
|
|
27
|
+
const textareaRef = ref(null);
|
|
28
|
+
const mentionDropdownRef = ref(null);
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
suggestions,
|
|
32
|
+
isActive: mentionActive,
|
|
33
|
+
loading: mentionLoading,
|
|
34
|
+
onTextChange,
|
|
35
|
+
insertMention,
|
|
36
|
+
close: closeMentions,
|
|
37
|
+
} = useMentions();
|
|
38
|
+
|
|
39
|
+
function onMentionInput() {
|
|
40
|
+
if (textareaRef.value) {
|
|
41
|
+
onTextChange(textareaRef.value, body.value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function handleMentionSelect(agent) {
|
|
46
|
+
body.value = insertMention(body.value, agent);
|
|
47
|
+
textareaRef.value?.focus();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function handleMentionKeydown(e) {
|
|
51
|
+
if (mentionActive.value && mentionDropdownRef.value) {
|
|
52
|
+
const handled = mentionDropdownRef.value.onKeydown(e);
|
|
53
|
+
if (handled) return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
25
56
|
|
|
26
57
|
// Typing indicator: debounced POST every 5 seconds while typing
|
|
27
58
|
let lastTypingSent = 0;
|
|
@@ -143,15 +174,31 @@ const buttonLabel = computed(() => {
|
|
|
143
174
|
This note is only visible to agents.
|
|
144
175
|
</div>
|
|
145
176
|
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
177
|
+
<div class="relative">
|
|
178
|
+
<textarea
|
|
179
|
+
ref="textareaRef"
|
|
180
|
+
v-model="body"
|
|
181
|
+
rows="4"
|
|
182
|
+
:placeholder="placeholder"
|
|
183
|
+
aria-label="Reply message"
|
|
184
|
+
class="w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
|
185
|
+
@keydown="
|
|
186
|
+
handleMentionKeydown($event);
|
|
187
|
+
onTyping();
|
|
188
|
+
"
|
|
189
|
+
@input="onMentionInput"
|
|
190
|
+
@blur="onBlur"
|
|
191
|
+
></textarea>
|
|
192
|
+
<MentionDropdown
|
|
193
|
+
ref="mentionDropdownRef"
|
|
194
|
+
:suggestions="suggestions"
|
|
195
|
+
:visible="mentionActive"
|
|
196
|
+
:loading="mentionLoading"
|
|
197
|
+
class="bottom-0 left-0 translate-y-full"
|
|
198
|
+
@select="handleMentionSelect"
|
|
199
|
+
@close="closeMentions"
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
155
202
|
|
|
156
203
|
<FileDropzone class="mt-2" @files="handleFiles" />
|
|
157
204
|
|
|
@@ -222,15 +269,31 @@ const buttonLabel = computed(() => {
|
|
|
222
269
|
This note is only visible to agents.
|
|
223
270
|
</div>
|
|
224
271
|
|
|
225
|
-
<
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
272
|
+
<div class="relative">
|
|
273
|
+
<textarea
|
|
274
|
+
ref="textareaRef"
|
|
275
|
+
v-model="body"
|
|
276
|
+
rows="4"
|
|
277
|
+
:placeholder="placeholder"
|
|
278
|
+
aria-label="Reply message"
|
|
279
|
+
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
280
|
+
@keydown="
|
|
281
|
+
handleMentionKeydown($event);
|
|
282
|
+
onTyping();
|
|
283
|
+
"
|
|
284
|
+
@input="onMentionInput"
|
|
285
|
+
@blur="onBlur"
|
|
286
|
+
></textarea>
|
|
287
|
+
<MentionDropdown
|
|
288
|
+
ref="mentionDropdownRef"
|
|
289
|
+
:suggestions="suggestions"
|
|
290
|
+
:visible="mentionActive"
|
|
291
|
+
:loading="mentionLoading"
|
|
292
|
+
class="bottom-0 left-0 translate-y-full"
|
|
293
|
+
@select="handleMentionSelect"
|
|
294
|
+
@close="closeMentions"
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
234
297
|
|
|
235
298
|
<FileDropzone class="mt-2" @files="handleFiles" />
|
|
236
299
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { inject, computed } from 'vue';
|
|
2
|
+
import { ref, inject, computed } from 'vue';
|
|
3
3
|
import { router } from '@inertiajs/vue3';
|
|
4
4
|
import AttachmentList from './AttachmentList.vue';
|
|
5
|
+
import TicketSplitDialog from './TicketSplitDialog.vue';
|
|
5
6
|
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
|
7
|
+
import { highlightMentions } from '../utils/mentionHighlight';
|
|
6
8
|
import { useI18n } from '../composables/useI18n';
|
|
7
9
|
|
|
8
10
|
const props = defineProps({
|
|
@@ -11,8 +13,24 @@ const props = defineProps({
|
|
|
11
13
|
ticketReference: { type: String, default: '' },
|
|
12
14
|
routePrefix: { type: String, default: '' },
|
|
13
15
|
pinnable: { type: Boolean, default: false },
|
|
16
|
+
splittable: { type: Boolean, default: false },
|
|
14
17
|
});
|
|
15
18
|
|
|
19
|
+
const splitReply = ref(null);
|
|
20
|
+
|
|
21
|
+
function openSplitDialog(reply) {
|
|
22
|
+
splitReply.value = reply;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function closeSplitDialog() {
|
|
26
|
+
splitReply.value = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stripHtml(html) {
|
|
30
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
31
|
+
return doc.body.textContent || '';
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
const escDark = inject(
|
|
17
35
|
'esc-dark',
|
|
18
36
|
computed(() => false),
|
|
@@ -75,6 +93,18 @@ function togglePin(reply) {
|
|
|
75
93
|
</span>
|
|
76
94
|
</div>
|
|
77
95
|
<div class="flex items-center gap-2">
|
|
96
|
+
<button
|
|
97
|
+
v-if="splittable && !reply.is_internal_note"
|
|
98
|
+
:class="[
|
|
99
|
+
'rounded px-2 py-0.5 text-xs font-medium transition-colors',
|
|
100
|
+
escDark
|
|
101
|
+
? 'text-[var(--esc-panel-text-muted)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]'
|
|
102
|
+
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-600',
|
|
103
|
+
]"
|
|
104
|
+
@click="openSplitDialog(reply)"
|
|
105
|
+
>
|
|
106
|
+
{{ t('reply.split') }}
|
|
107
|
+
</button>
|
|
78
108
|
<button
|
|
79
109
|
v-if="pinnable && reply.is_internal_note"
|
|
80
110
|
:class="[
|
|
@@ -101,7 +131,7 @@ function togglePin(reply) {
|
|
|
101
131
|
'prose prose-sm max-w-none',
|
|
102
132
|
escDark ? 'prose-invert text-[var(--esc-panel-text-secondary)]' : 'text-gray-700',
|
|
103
133
|
]"
|
|
104
|
-
v-html="sanitizeHtml(reply.body)"
|
|
134
|
+
v-html="highlightMentions(sanitizeHtml(reply.body), escDark)"
|
|
105
135
|
></div>
|
|
106
136
|
<AttachmentList v-if="reply.attachments?.length" :attachments="reply.attachments" class="mt-3" />
|
|
107
137
|
</div>
|
|
@@ -111,5 +141,13 @@ function togglePin(reply) {
|
|
|
111
141
|
>
|
|
112
142
|
{{ t('ticket.no_replies') }}
|
|
113
143
|
</div>
|
|
144
|
+
<TicketSplitDialog
|
|
145
|
+
v-if="splitReply"
|
|
146
|
+
:ticket-reference="ticketReference"
|
|
147
|
+
:reply-id="splitReply.id"
|
|
148
|
+
:reply-author="splitReply.author?.name || ''"
|
|
149
|
+
:reply-excerpt="stripHtml(splitReply.body)"
|
|
150
|
+
@close="closeSplitDialog"
|
|
151
|
+
/>
|
|
114
152
|
</div>
|
|
115
153
|
</template>
|