@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.
Files changed (179) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +31 -0
  3. package/package.json +12 -6
  4. package/src/components/ActiveChatsPanel.vue +246 -0
  5. package/src/components/ActivityTimeline.stories.js +55 -55
  6. package/src/components/ActivityTimeline.vue +83 -83
  7. package/src/components/AdminDashboard.stories.js +1423 -1095
  8. package/src/components/AgentLoadIndicator.stories.js +37 -37
  9. package/src/components/AgentLoadIndicator.vue +49 -49
  10. package/src/components/AgentPerformanceCard.vue +44 -44
  11. package/src/components/ArticleEditor.vue +82 -82
  12. package/src/components/AssigneeSelect.vue +37 -37
  13. package/src/components/AttachmentList.vue +13 -1
  14. package/src/components/AuditLogEntry.vue +95 -95
  15. package/src/components/BulkActionBar.vue +166 -166
  16. package/src/components/CategoryTree.vue +89 -89
  17. package/src/components/ChartWidget.vue +46 -46
  18. package/src/components/ChatActionBar.vue +226 -0
  19. package/src/components/ChatBubble.vue +162 -0
  20. package/src/components/ChatComposer.vue +308 -0
  21. package/src/components/ChatQueue.vue +222 -0
  22. package/src/components/ChatThread.vue +197 -0
  23. package/src/components/CollisionWarning.stories.js +30 -30
  24. package/src/components/CollisionWarning.vue +68 -68
  25. package/src/components/ConditionalFieldRules.vue +135 -135
  26. package/src/components/ContextPanel.vue +66 -66
  27. package/src/components/ContextPanelSection.vue +65 -65
  28. package/src/components/CustomFieldRenderer.vue +159 -159
  29. package/src/components/EscalatedLayout.vue +29 -6
  30. package/src/components/FileDropzone.vue +57 -57
  31. package/src/components/FollowButton.stories.js +30 -30
  32. package/src/components/FollowButton.vue +88 -88
  33. package/src/components/KeyboardShortcutHelp.vue +213 -213
  34. package/src/components/KnowledgePanel.vue +122 -122
  35. package/src/components/KpiCard.stories.js +32 -32
  36. package/src/components/KpiCard.vue +88 -88
  37. package/src/components/MacroDropdown.vue +150 -150
  38. package/src/components/MentionDropdown.vue +154 -0
  39. package/src/components/MetricComparison.vue +89 -0
  40. package/src/components/PercentileChart.vue +67 -0
  41. package/src/components/PeriodSelector.vue +98 -0
  42. package/src/components/PermissionMatrix.vue +107 -107
  43. package/src/components/PinnedNotes.vue +129 -129
  44. package/src/components/PluginSlot.vue +68 -62
  45. package/src/components/PresenceIndicator.vue +46 -2
  46. package/src/components/PriorityBadge.stories.js +33 -33
  47. package/src/components/PriorityBadge.vue +46 -46
  48. package/src/components/ReplyComposer.vue +81 -18
  49. package/src/components/ReplyThread.vue +40 -2
  50. package/src/components/ReportChart.vue +395 -0
  51. package/src/components/ReportExportButton.vue +108 -0
  52. package/src/components/SatisfactionRating.vue +180 -180
  53. package/src/components/SaveViewDialog.vue +172 -0
  54. package/src/components/SavedViewSidebar.vue +208 -0
  55. package/src/components/ScheduleEditor.vue +104 -104
  56. package/src/components/SideConversation.vue +165 -165
  57. package/src/components/SideConversationList.vue +147 -147
  58. package/src/components/SkillTagManager.vue +79 -79
  59. package/src/components/SlaComplianceChart.vue +46 -53
  60. package/src/components/SlaTimer.stories.js +35 -35
  61. package/src/components/SlaTimer.vue +62 -62
  62. package/src/components/SnoozeButton.vue +247 -0
  63. package/src/components/StatsCard.stories.js +24 -24
  64. package/src/components/StatsCard.vue +58 -58
  65. package/src/components/StatusBadge.stories.js +53 -53
  66. package/src/components/StatusBadge.vue +78 -78
  67. package/src/components/TagSelect.vue +84 -84
  68. package/src/components/TicketFilters.vue +270 -225
  69. package/src/components/TicketLinkPanel.vue +193 -193
  70. package/src/components/TicketList.vue +655 -594
  71. package/src/components/TicketMergeDialog.vue +157 -157
  72. package/src/components/TicketSplitDialog.vue +98 -0
  73. package/src/components/TotpInput.stories.js +9 -9
  74. package/src/components/TotpInput.vue +81 -81
  75. package/src/components/TwoFactorSetup.vue +113 -113
  76. package/src/components/VariableTokenInput.vue +111 -0
  77. package/src/components/WorkflowActionConfig.vue +287 -0
  78. package/src/components/WorkflowActionList.vue +350 -0
  79. package/src/components/WorkflowConditionBuilder.vue +265 -0
  80. package/src/components/WorkflowTriggerSelector.vue +125 -0
  81. package/src/composables/useChat.js +187 -0
  82. package/src/composables/useI18n.js +80 -80
  83. package/src/composables/useKeyboardShortcuts.js +46 -46
  84. package/src/composables/useMentions.js +134 -0
  85. package/src/composables/usePluginExtensions.js +159 -159
  86. package/src/composables/usePluginHooks.js +65 -65
  87. package/src/composables/useRealtime.js +182 -0
  88. package/src/defineEscalatedPlugin.js +76 -76
  89. package/src/index.js +84 -72
  90. package/src/locales/ar.json +727 -0
  91. package/src/locales/de.json +678 -430
  92. package/src/locales/en.json +717 -455
  93. package/src/locales/es.json +679 -430
  94. package/src/locales/fr.json +680 -431
  95. package/src/locales/index.js +12 -2
  96. package/src/locales/it.json +727 -0
  97. package/src/locales/ja.json +727 -0
  98. package/src/locales/ko.json +727 -0
  99. package/src/locales/nl.json +727 -0
  100. package/src/locales/pl.json +727 -0
  101. package/src/locales/pt-BR.json +727 -0
  102. package/src/locales/ru.json +727 -0
  103. package/src/locales/tr.json +727 -0
  104. package/src/locales/zh-CN.json +727 -0
  105. package/src/pages/Admin/ApiTokens/Index.vue +381 -381
  106. package/src/pages/Admin/AuditLog/Index.vue +154 -154
  107. package/src/pages/Admin/BusinessHours/Form.vue +172 -172
  108. package/src/pages/Admin/BusinessHours/Index.vue +113 -113
  109. package/src/pages/Admin/CannedResponses/Index.vue +127 -127
  110. package/src/pages/Admin/Capacity/Index.vue +134 -134
  111. package/src/pages/Admin/CustomFields/Form.vue +195 -195
  112. package/src/pages/Admin/CustomFields/Index.vue +134 -134
  113. package/src/pages/Admin/CustomObjects/Form.vue +202 -202
  114. package/src/pages/Admin/CustomObjects/Index.vue +127 -127
  115. package/src/pages/Admin/CustomObjects/Records.vue +228 -228
  116. package/src/pages/Admin/Departments/Form.vue +75 -75
  117. package/src/pages/Admin/Departments/Index.vue +110 -109
  118. package/src/pages/Admin/EscalationRules/Form.vue +157 -157
  119. package/src/pages/Admin/EscalationRules/Index.vue +178 -178
  120. package/src/pages/Admin/Import/Connect.vue +0 -1
  121. package/src/pages/Admin/Import/Index.vue +1 -1
  122. package/src/pages/Admin/Import/Progress.vue +1 -1
  123. package/src/pages/Admin/KnowledgeBase/Articles/Form.vue +142 -142
  124. package/src/pages/Admin/KnowledgeBase/Articles/Index.vue +195 -195
  125. package/src/pages/Admin/KnowledgeBase/Categories/Index.vue +193 -193
  126. package/src/pages/Admin/Macros/Index.vue +448 -448
  127. package/src/pages/Admin/Plugins/Index.vue +327 -327
  128. package/src/pages/Admin/Reports/AgentDetail.vue +132 -0
  129. package/src/pages/Admin/Reports/AgentMetrics.vue +115 -115
  130. package/src/pages/Admin/Reports/AgentRanking.vue +246 -0
  131. package/src/pages/Admin/Reports/Cohorts.vue +180 -0
  132. package/src/pages/Admin/Reports/Comparison.vue +141 -0
  133. package/src/pages/Admin/Reports/CsatReport.vue +127 -127
  134. package/src/pages/Admin/Reports/Dashboard.vue +36 -21
  135. package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -0
  136. package/src/pages/Admin/Reports/ResponseTimes.vue +164 -0
  137. package/src/pages/Admin/Reports/SlaReport.vue +162 -166
  138. package/src/pages/Admin/Reports/SlaTrends.vue +183 -0
  139. package/src/pages/Admin/Reports.vue +206 -206
  140. package/src/pages/Admin/Roles/Form.vue +71 -71
  141. package/src/pages/Admin/Roles/Index.vue +122 -122
  142. package/src/pages/Admin/Settings/CsatSettings.vue +165 -165
  143. package/src/pages/Admin/Settings/DataRetention.vue +182 -182
  144. package/src/pages/Admin/Settings/EmailSettings.vue +243 -243
  145. package/src/pages/Admin/Settings/Sandbox.vue +135 -135
  146. package/src/pages/Admin/Settings/SsoSettings.vue +218 -218
  147. package/src/pages/Admin/Settings/TwoFactor.vue +110 -110
  148. package/src/pages/Admin/Settings.vue +1008 -468
  149. package/src/pages/Admin/Skills/Form.vue +53 -53
  150. package/src/pages/Admin/Skills/Index.vue +94 -94
  151. package/src/pages/Admin/SlaPolicies/Form.vue +118 -118
  152. package/src/pages/Admin/SlaPolicies/Index.vue +121 -120
  153. package/src/pages/Admin/Statuses/Form.vue +114 -114
  154. package/src/pages/Admin/Statuses/Index.vue +153 -153
  155. package/src/pages/Admin/Tags/Index.vue +191 -191
  156. package/src/pages/Admin/Tickets/Index.vue +126 -91
  157. package/src/pages/Admin/Tickets/Show.vue +29 -0
  158. package/src/pages/Admin/Webhooks/DeliveryLog.vue +120 -120
  159. package/src/pages/Admin/Webhooks/Form.vue +110 -110
  160. package/src/pages/Admin/Webhooks/Index.vue +136 -136
  161. package/src/pages/Admin/Workflows/Builder.vue +207 -0
  162. package/src/pages/Admin/Workflows/Index.vue +235 -0
  163. package/src/pages/Admin/Workflows/Logs.vue +286 -0
  164. package/src/pages/Agent/TicketIndex.vue +112 -88
  165. package/src/pages/Agent/TicketShow.vue +192 -60
  166. package/src/pages/Customer/Create.vue +1 -1
  167. package/src/pages/Customer/Index.vue +31 -31
  168. package/src/pages/Customer/KnowledgeBase/Article.vue +4 -2
  169. package/src/pages/Customer/KnowledgeBase/Index.vue +151 -151
  170. package/src/pages/Customer/Show.vue +69 -63
  171. package/src/pages/Guest/Create.vue +40 -15
  172. package/src/pages/Guest/Show.vue +124 -93
  173. package/src/plugin.js +25 -0
  174. package/src/utils/mentionHighlight.js +24 -0
  175. package/src/utils/sanitizeHtml.js +78 -64
  176. package/src/widget/EscalatedWidget.vue +804 -0
  177. package/src/widget/index.js +254 -0
  178. package/src/pages/Admin/Automations/Form.vue +0 -184
  179. package/src/pages/Admin/Automations/Index.vue +0 -129
@@ -1,62 +1,68 @@
1
- <script setup>
2
- import { defineAsyncComponent, h } from 'vue';
3
-
4
- const props = 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('div', {
20
- class: 'rounded-lg border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-xs text-rose-400',
21
- }, `Plugin "${this.plugin}" failed to load component "${this.component}"`);
22
- },
23
- };
24
-
25
- // Loading placeholder component
26
- const LoadingFallback = {
27
- render() {
28
- return h('div', {
29
- class: 'animate-pulse rounded-lg bg-white/[0.03] px-3 py-2',
30
- }, [
31
- h('div', { class: 'h-4 w-24 rounded bg-white/[0.06]' }),
32
- ]);
33
- },
34
- };
35
-
36
- // Load plugin components dynamically from host app's plugins directory
37
- const loadPluginComponent = (plugin, component) => {
38
- return defineAsyncComponent({
39
- loader: () => import(`../../../../plugins/escalated/${plugin}/resources/js/Components/${component}.vue`),
40
- loadingComponent: LoadingFallback,
41
- errorComponent: {
42
- props: ['error'],
43
- render() {
44
- return h(ErrorFallback, { error: this.error, plugin, component });
45
- },
46
- },
47
- delay: 100,
48
- timeout: 10000,
49
- });
50
- };
51
- </script>
52
-
53
- <template>
54
- <template v-if="components?.length > 0">
55
- <component
56
- v-for="(pluginComp, index) in components"
57
- :key="`${slot}-${pluginComp.plugin}-${pluginComp.component}-${index}`"
58
- :is="loadPluginComponent(pluginComp.plugin, pluginComp.component)"
59
- v-bind="pluginComp.data || {}"
60
- />
61
- </template>
62
- </template>
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
- fetchPresence();
72
- intervalId = setInterval(fetchPresence, effectiveInterval.value);
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
- <textarea
147
- v-model="body"
148
- rows="4"
149
- :placeholder="placeholder"
150
- aria-label="Reply message"
151
- 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"
152
- @keydown="onTyping"
153
- @blur="onBlur"
154
- ></textarea>
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
- <textarea
226
- v-model="body"
227
- rows="4"
228
- :placeholder="placeholder"
229
- aria-label="Reply message"
230
- 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"
231
- @keydown="onTyping"
232
- @blur="onBlur"
233
- ></textarea>
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>