@escalated-dev/escalated 0.6.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 (187) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +125 -2
  3. package/package.json +40 -5
  4. package/src/components/ActiveChatsPanel.vue +246 -0
  5. package/src/components/ActivityTimeline.stories.js +55 -0
  6. package/src/components/ActivityTimeline.vue +83 -66
  7. package/src/components/AdminDashboard.stories.js +1423 -0
  8. package/src/components/AgentLoadIndicator.stories.js +37 -0
  9. package/src/components/AgentLoadIndicator.vue +49 -0
  10. package/src/components/AgentPerformanceCard.vue +44 -0
  11. package/src/components/ArticleEditor.vue +82 -0
  12. package/src/components/AssigneeSelect.vue +37 -23
  13. package/src/components/AttachmentList.vue +65 -37
  14. package/src/components/AuditLogEntry.vue +95 -0
  15. package/src/components/BulkActionBar.vue +166 -97
  16. package/src/components/CategoryTree.vue +89 -0
  17. package/src/components/ChartWidget.vue +46 -0
  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 -0
  24. package/src/components/CollisionWarning.vue +68 -0
  25. package/src/components/ConditionalFieldRules.vue +135 -0
  26. package/src/components/ContextPanel.vue +66 -0
  27. package/src/components/ContextPanelSection.vue +65 -0
  28. package/src/components/CustomFieldRenderer.vue +159 -0
  29. package/src/components/EscalatedLayout.vue +424 -70
  30. package/src/components/FileDropzone.vue +57 -41
  31. package/src/components/FollowButton.stories.js +30 -0
  32. package/src/components/FollowButton.vue +88 -63
  33. package/src/components/KeyboardShortcutHelp.vue +213 -123
  34. package/src/components/KnowledgePanel.vue +122 -0
  35. package/src/components/KpiCard.stories.js +32 -0
  36. package/src/components/KpiCard.vue +88 -0
  37. package/src/components/MacroDropdown.vue +150 -90
  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 -0
  43. package/src/components/PinnedNotes.vue +129 -90
  44. package/src/components/PluginSlot.vue +68 -62
  45. package/src/components/PresenceIndicator.vue +138 -28
  46. package/src/components/PriorityBadge.stories.js +33 -0
  47. package/src/components/PriorityBadge.vue +46 -39
  48. package/src/components/QuickFilters.vue +65 -49
  49. package/src/components/ReplyComposer.vue +207 -30
  50. package/src/components/ReplyThread.vue +111 -21
  51. package/src/components/ReportChart.vue +395 -0
  52. package/src/components/ReportExportButton.vue +108 -0
  53. package/src/components/SatisfactionRating.vue +180 -128
  54. package/src/components/SaveViewDialog.vue +172 -0
  55. package/src/components/SavedViewSidebar.vue +208 -0
  56. package/src/components/ScheduleEditor.vue +104 -0
  57. package/src/components/SideConversation.vue +165 -0
  58. package/src/components/SideConversationList.vue +147 -0
  59. package/src/components/SkillTagManager.vue +79 -0
  60. package/src/components/SlaComplianceChart.vue +46 -0
  61. package/src/components/SlaTimer.stories.js +35 -0
  62. package/src/components/SlaTimer.vue +62 -49
  63. package/src/components/SnoozeButton.vue +247 -0
  64. package/src/components/StatsCard.stories.js +24 -0
  65. package/src/components/StatsCard.vue +18 -6
  66. package/src/components/StatusBadge.stories.js +53 -0
  67. package/src/components/StatusBadge.vue +78 -46
  68. package/src/components/TagSelect.vue +84 -53
  69. package/src/components/TicketFilters.vue +247 -46
  70. package/src/components/TicketLinkPanel.vue +193 -0
  71. package/src/components/TicketList.vue +536 -74
  72. package/src/components/TicketMergeDialog.vue +157 -0
  73. package/src/components/TicketSidebar.vue +167 -99
  74. package/src/components/TicketSplitDialog.vue +98 -0
  75. package/src/components/TicketTypeSelector.vue +21 -0
  76. package/src/components/TotpInput.stories.js +9 -0
  77. package/src/components/TotpInput.vue +81 -0
  78. package/src/components/TwoFactorSetup.vue +113 -0
  79. package/src/components/VariableTokenInput.vue +111 -0
  80. package/src/components/WorkflowActionConfig.vue +287 -0
  81. package/src/components/WorkflowActionList.vue +350 -0
  82. package/src/components/WorkflowConditionBuilder.vue +265 -0
  83. package/src/components/WorkflowTriggerSelector.vue +125 -0
  84. package/src/composables/useChat.js +187 -0
  85. package/src/composables/useDebouncedSearch.js +38 -0
  86. package/src/composables/useI18n.js +80 -80
  87. package/src/composables/useKeyboardShortcuts.js +46 -46
  88. package/src/composables/useMentions.js +134 -0
  89. package/src/composables/usePluginExtensions.js +159 -67
  90. package/src/composables/usePluginHooks.js +65 -0
  91. package/src/composables/useRealtime.js +182 -0
  92. package/src/defineEscalatedPlugin.js +76 -50
  93. package/src/index.js +77 -28
  94. package/src/locales/ar.json +727 -0
  95. package/src/locales/de.json +678 -430
  96. package/src/locales/en.json +717 -430
  97. package/src/locales/es.json +679 -430
  98. package/src/locales/fr.json +680 -431
  99. package/src/locales/index.js +12 -2
  100. package/src/locales/it.json +727 -0
  101. package/src/locales/ja.json +727 -0
  102. package/src/locales/ko.json +727 -0
  103. package/src/locales/nl.json +727 -0
  104. package/src/locales/pl.json +727 -0
  105. package/src/locales/pt-BR.json +727 -0
  106. package/src/locales/ru.json +727 -0
  107. package/src/locales/tr.json +727 -0
  108. package/src/locales/zh-CN.json +727 -0
  109. package/src/pages/Admin/ApiTokens/Index.vue +381 -217
  110. package/src/pages/Admin/AuditLog/Index.vue +154 -0
  111. package/src/pages/Admin/BusinessHours/Form.vue +172 -0
  112. package/src/pages/Admin/BusinessHours/Index.vue +113 -0
  113. package/src/pages/Admin/CannedResponses/Index.vue +82 -25
  114. package/src/pages/Admin/Capacity/Index.vue +134 -0
  115. package/src/pages/Admin/CustomFields/Form.vue +195 -0
  116. package/src/pages/Admin/CustomFields/Index.vue +134 -0
  117. package/src/pages/Admin/CustomObjects/Form.vue +202 -0
  118. package/src/pages/Admin/CustomObjects/Index.vue +127 -0
  119. package/src/pages/Admin/CustomObjects/Records.vue +228 -0
  120. package/src/pages/Admin/Departments/Form.vue +35 -11
  121. package/src/pages/Admin/Departments/Index.vue +74 -21
  122. package/src/pages/Admin/EscalationRules/Form.vue +86 -25
  123. package/src/pages/Admin/EscalationRules/Index.vue +158 -37
  124. package/src/pages/Admin/Import/Connect.vue +95 -0
  125. package/src/pages/Admin/Import/Index.vue +102 -0
  126. package/src/pages/Admin/Import/Mapping.vue +110 -0
  127. package/src/pages/Admin/Import/Progress.vue +153 -0
  128. package/src/pages/Admin/Import/Review.vue +76 -0
  129. package/src/pages/Admin/KnowledgeBase/Articles/Form.vue +142 -0
  130. package/src/pages/Admin/KnowledgeBase/Articles/Index.vue +195 -0
  131. package/src/pages/Admin/KnowledgeBase/Categories/Index.vue +193 -0
  132. package/src/pages/Admin/Macros/Index.vue +448 -287
  133. package/src/pages/Admin/Plugins/Index.vue +327 -197
  134. package/src/pages/Admin/Reports/AgentDetail.vue +132 -0
  135. package/src/pages/Admin/Reports/AgentMetrics.vue +115 -0
  136. package/src/pages/Admin/Reports/AgentRanking.vue +246 -0
  137. package/src/pages/Admin/Reports/Cohorts.vue +180 -0
  138. package/src/pages/Admin/Reports/Comparison.vue +141 -0
  139. package/src/pages/Admin/Reports/CsatReport.vue +127 -0
  140. package/src/pages/Admin/Reports/Dashboard.vue +226 -0
  141. package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -0
  142. package/src/pages/Admin/Reports/ResponseTimes.vue +164 -0
  143. package/src/pages/Admin/Reports/SlaReport.vue +162 -0
  144. package/src/pages/Admin/Reports/SlaTrends.vue +183 -0
  145. package/src/pages/Admin/Reports.vue +206 -101
  146. package/src/pages/Admin/Roles/Form.vue +71 -0
  147. package/src/pages/Admin/Roles/Index.vue +122 -0
  148. package/src/pages/Admin/Settings/CsatSettings.vue +165 -0
  149. package/src/pages/Admin/Settings/DataRetention.vue +182 -0
  150. package/src/pages/Admin/Settings/EmailSettings.vue +243 -0
  151. package/src/pages/Admin/Settings/Sandbox.vue +135 -0
  152. package/src/pages/Admin/Settings/SsoSettings.vue +218 -0
  153. package/src/pages/Admin/Settings/TwoFactor.vue +110 -0
  154. package/src/pages/Admin/Settings.vue +847 -99
  155. package/src/pages/Admin/Skills/Form.vue +53 -0
  156. package/src/pages/Admin/Skills/Index.vue +94 -0
  157. package/src/pages/Admin/SlaPolicies/Form.vue +62 -19
  158. package/src/pages/Admin/SlaPolicies/Index.vue +85 -23
  159. package/src/pages/Admin/Statuses/Form.vue +114 -0
  160. package/src/pages/Admin/Statuses/Index.vue +153 -0
  161. package/src/pages/Admin/Tags/Index.vue +121 -35
  162. package/src/pages/Admin/Tickets/Index.vue +96 -9
  163. package/src/pages/Admin/Tickets/Show.vue +130 -37
  164. package/src/pages/Admin/Webhooks/DeliveryLog.vue +120 -0
  165. package/src/pages/Admin/Webhooks/Form.vue +110 -0
  166. package/src/pages/Admin/Webhooks/Index.vue +136 -0
  167. package/src/pages/Admin/Workflows/Builder.vue +207 -0
  168. package/src/pages/Admin/Workflows/Index.vue +235 -0
  169. package/src/pages/Admin/Workflows/Logs.vue +286 -0
  170. package/src/pages/Agent/Dashboard.vue +311 -186
  171. package/src/pages/Agent/TicketIndex.vue +80 -6
  172. package/src/pages/Agent/TicketShow.vue +399 -60
  173. package/src/pages/Customer/Create.vue +37 -10
  174. package/src/pages/Customer/Index.vue +9 -2
  175. package/src/pages/Customer/KnowledgeBase/Article.vue +129 -0
  176. package/src/pages/Customer/KnowledgeBase/Index.vue +151 -0
  177. package/src/pages/Customer/Show.vue +69 -63
  178. package/src/pages/Guest/Create.vue +40 -15
  179. package/src/pages/Guest/Show.vue +124 -93
  180. package/src/plugin.js +111 -1
  181. package/src/utils/constants.js +30 -0
  182. package/src/utils/formatting.js +35 -0
  183. package/src/utils/mentionHighlight.js +24 -0
  184. package/src/utils/sanitizeHtml.js +78 -64
  185. package/src/widget/EscalatedWidget.vue +804 -0
  186. package/src/widget/index.js +254 -0
  187. package/src/pages/Admin/Plugins.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,20 +3,30 @@ 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 },
8
+ pollInterval: { type: Number, default: 30000 },
9
+ showLabel: { type: Boolean, default: false },
7
10
  });
8
11
 
9
- const escDark = inject('esc-dark', computed(() => false));
12
+ const effectiveInterval = computed(() => Math.max(props.pollInterval, 5000));
13
+ const echoAvailable = typeof window !== 'undefined' && !!window.Echo;
14
+
15
+ const escDark = inject(
16
+ 'esc-dark',
17
+ computed(() => false),
18
+ );
10
19
  const viewers = ref([]);
11
20
  const hovering = ref(false);
12
21
  let intervalId = null;
22
+ let presenceChannel = null;
13
23
 
14
24
  async function fetchPresence() {
15
25
  try {
16
26
  const response = await fetch(route(`${props.routePrefix}.tickets.presence`, props.ticketReference), {
17
27
  method: 'POST',
18
28
  headers: {
19
- 'Accept': 'application/json',
29
+ Accept: 'application/json',
20
30
  'Content-Type': 'application/json',
21
31
  'X-Requested-With': 'XMLHttpRequest',
22
32
  'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
@@ -31,9 +41,38 @@ async function fetchPresence() {
31
41
  }
32
42
  }
33
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
+
34
73
  const tooltipText = computed(() => {
35
74
  if (!viewers.value.length) return '';
36
- const names = viewers.value.map(v => v.name);
75
+ const names = viewers.value.map((v) => v.name);
37
76
  return 'Also viewing: ' + names.join(', ');
38
77
  });
39
78
 
@@ -46,8 +85,14 @@ function getInitial(viewer) {
46
85
  }
47
86
 
48
87
  const colors = [
49
- 'bg-cyan-500', 'bg-violet-500', 'bg-amber-500', 'bg-emerald-500',
50
- 'bg-rose-500', 'bg-blue-500', 'bg-pink-500', 'bg-teal-500',
88
+ 'bg-cyan-500',
89
+ 'bg-violet-500',
90
+ 'bg-amber-500',
91
+ 'bg-emerald-500',
92
+ 'bg-rose-500',
93
+ 'bg-blue-500',
94
+ 'bg-pink-500',
95
+ 'bg-teal-500',
51
96
  ];
52
97
 
53
98
  function getColor(index) {
@@ -55,8 +100,12 @@ function getColor(index) {
55
100
  }
56
101
 
57
102
  onMounted(() => {
58
- fetchPresence();
59
- intervalId = setInterval(fetchPresence, 30000);
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
+ }
60
109
  });
61
110
 
62
111
  onUnmounted(() => {
@@ -64,39 +113,100 @@ onUnmounted(() => {
64
113
  clearInterval(intervalId);
65
114
  intervalId = null;
66
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
+ }
67
124
  });
68
125
  </script>
69
126
 
70
127
  <template>
71
- <div v-if="viewers.length > 0" class="relative" @mouseenter="hovering = true" @mouseleave="hovering = false">
128
+ <div
129
+ v-if="viewers.length > 0"
130
+ class="relative flex items-center"
131
+ @mouseenter="hovering = true"
132
+ @mouseleave="hovering = false"
133
+ >
72
134
  <!-- Avatar stack -->
73
- <div class="flex -space-x-2">
74
- <div v-for="(viewer, index) in visibleViewers" :key="viewer.id || index"
75
- :class="['flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold text-white ring-2',
76
- getColor(index),
77
- escDark ? 'ring-neutral-900' : 'ring-white']">
135
+ <TransitionGroup
136
+ tag="div"
137
+ class="flex -space-x-2"
138
+ enter-active-class="transition-all duration-300 ease-out"
139
+ enter-from-class="opacity-0 scale-75 -ml-7"
140
+ enter-to-class="opacity-100 scale-100 ml-0"
141
+ leave-active-class="transition-all duration-200 ease-in"
142
+ leave-from-class="opacity-100 scale-100"
143
+ leave-to-class="opacity-0 scale-75 -ml-7"
144
+ >
145
+ <div
146
+ v-for="(viewer, index) in visibleViewers"
147
+ :key="viewer.id || viewer.name"
148
+ :aria-label="viewer.name"
149
+ :class="[
150
+ 'relative flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold text-white ring-2',
151
+ getColor(index),
152
+ escDark ? 'ring-neutral-900' : 'ring-white',
153
+ ]"
154
+ >
78
155
  {{ getInitial(viewer) }}
156
+ <!-- Pulsing live indicator on first avatar -->
157
+ <span v-if="index === 0" class="absolute -right-0.5 -top-0.5 flex h-2.5 w-2.5">
158
+ <span
159
+ class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"
160
+ ></span>
161
+ <span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500"></span>
162
+ </span>
79
163
  </div>
80
- <div v-if="overflowCount > 0"
81
- :class="['flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold ring-2',
82
- escDark
83
- ? 'bg-white/[0.08] text-neutral-300 ring-neutral-900'
84
- : 'bg-gray-200 text-gray-600 ring-white']">
164
+ <div
165
+ v-if="overflowCount > 0"
166
+ :key="'overflow'"
167
+ :class="[
168
+ 'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold ring-2',
169
+ escDark
170
+ ? 'bg-white/[0.08] text-neutral-300 ring-neutral-900'
171
+ : 'bg-gray-200 text-gray-600 ring-white',
172
+ ]"
173
+ >
85
174
  +{{ overflowCount }}
86
175
  </div>
87
- </div>
176
+ </TransitionGroup>
177
+
178
+ <!-- Viewer count label -->
179
+ <span
180
+ v-if="showLabel"
181
+ aria-live="polite"
182
+ :class="['ml-2 text-xs font-medium', escDark ? 'text-neutral-400' : 'text-gray-500']"
183
+ >
184
+ {{ viewers.length }} viewing
185
+ </span>
88
186
 
89
187
  <!-- Tooltip -->
90
- <Transition enter-active-class="transition duration-100 ease-out" enter-from-class="opacity-0 translate-y-1" enter-to-class="opacity-100 translate-y-0"
91
- leave-active-class="transition duration-75 ease-in" leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-1">
92
- <div v-if="hovering"
93
- :class="['absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-medium shadow-lg',
94
- escDark
95
- ? 'border border-white/[0.06] bg-neutral-800 text-neutral-200'
96
- : 'bg-gray-900 text-white']">
188
+ <Transition
189
+ enter-active-class="transition duration-100 ease-out"
190
+ enter-from-class="opacity-0 translate-y-1"
191
+ enter-to-class="opacity-100 translate-y-0"
192
+ leave-active-class="transition duration-75 ease-in"
193
+ leave-from-class="opacity-100 translate-y-0"
194
+ leave-to-class="opacity-0 translate-y-1"
195
+ >
196
+ <div
197
+ v-if="hovering"
198
+ :class="[
199
+ 'absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-medium shadow-lg',
200
+ escDark ? 'border border-white/[0.06] bg-neutral-800 text-neutral-200' : 'bg-gray-900 text-white',
201
+ ]"
202
+ >
97
203
  {{ tooltipText }}
98
- <div :class="['absolute -top-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45',
99
- escDark ? 'bg-neutral-800' : 'bg-gray-900']"></div>
204
+ <div
205
+ :class="[
206
+ 'absolute -top-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45',
207
+ escDark ? 'bg-neutral-800' : 'bg-gray-900',
208
+ ]"
209
+ ></div>
100
210
  </div>
101
211
  </Transition>
102
212
  </div>
@@ -0,0 +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,39 +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('esc-dark', computed(() => false));
9
-
10
- const darkConfig = {
11
- low: { label: 'Low', color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' },
12
- medium: { label: 'Medium', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
13
- high: { label: 'High', color: 'bg-amber-500/10 text-amber-400 ring-1 ring-amber-500/20' },
14
- urgent: { label: 'Urgent', color: 'bg-orange-500/10 text-orange-400 ring-1 ring-orange-500/20' },
15
- critical: { label: 'Critical', color: 'bg-rose-500/10 text-rose-300 ring-1 ring-rose-500/20' },
16
- };
17
-
18
- const lightConfig = {
19
- low: { label: 'Low', color: 'bg-gray-100 text-gray-800' },
20
- medium: { label: 'Medium', color: 'bg-blue-100 text-blue-800' },
21
- high: { label: 'High', color: 'bg-yellow-100 text-yellow-800' },
22
- urgent: { label: 'Urgent', color: 'bg-orange-100 text-orange-800' },
23
- critical: { label: 'Critical', color: 'bg-red-100 text-red-800' },
24
- };
25
-
26
- const config = computed(() => {
27
- const map = escDark.value ? darkConfig : lightConfig;
28
- const fallback = escDark.value
29
- ? { label: props.priority, color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' }
30
- : { label: props.priority, color: 'bg-gray-100 text-gray-800' };
31
- return map[props.priority] || fallback;
32
- });
33
- </script>
34
-
35
- <template>
36
- <span :class="['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', config.color]">
37
- {{ config.label }}
38
- </span>
39
- </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>
@@ -1,49 +1,65 @@
1
- <script setup>
2
- import { ref, computed, inject } from 'vue';
3
-
4
- const props = defineProps({
5
- currentUserId: { type: [Number, String], default: null },
6
- });
7
-
8
- const emit = defineEmits(['filter']);
9
- const escDark = inject('esc-dark', computed(() => false));
10
- const activeChip = ref(null);
11
-
12
- const chips = computed(() => {
13
- const list = [
14
- { key: 'my_tickets', label: 'My Tickets', filter: { assigned_to: props.currentUserId } },
15
- { key: 'unassigned', label: 'Unassigned', filter: { unassigned: true } },
16
- { key: 'urgent', label: 'Urgent+', filter: { priority: 'urgent' } },
17
- { key: 'sla_breaching', label: 'SLA Breaching', filter: { sla_breached: true } },
18
- { key: 'following', label: 'Following', filter: { following: true } },
19
- ];
20
- if (props.currentUserId) return list;
21
- return list.filter(c => c.key !== 'my_tickets');
22
- });
23
-
24
- function toggle(chip) {
25
- if (activeChip.value === chip.key) {
26
- activeChip.value = null;
27
- emit('filter', {});
28
- } else {
29
- activeChip.value = chip.key;
30
- emit('filter', chip.filter);
31
- }
32
- }
33
- </script>
34
-
35
- <template>
36
- <div class="flex flex-wrap items-center gap-2">
37
- <button v-for="chip in chips" :key="chip.key" @click="toggle(chip)"
38
- :class="['rounded-full px-3 py-1.5 text-xs font-medium transition-all',
39
- activeChip === chip.key
40
- ? (escDark
41
- ? 'bg-gradient-to-r from-cyan-500/20 to-violet-500/20 text-white ring-1 ring-cyan-500/30'
42
- : 'bg-gradient-to-r from-blue-50 to-indigo-50 text-blue-700 ring-1 ring-blue-200')
43
- : (escDark
44
- ? 'bg-white/[0.04] text-neutral-400 ring-1 ring-white/[0.06] hover:bg-white/[0.06] hover:text-neutral-300'
45
- : 'bg-gray-50 text-gray-600 ring-1 ring-gray-200 hover:bg-gray-100')]">
46
- {{ chip.label }}
47
- </button>
48
- </div>
49
- </template>
1
+ <script setup>
2
+ import { ref, computed, inject } from 'vue';
3
+
4
+ const props = defineProps({
5
+ currentUserId: { type: [Number, String], default: null },
6
+ });
7
+
8
+ const emit = defineEmits(['filter']);
9
+ const escDark = inject(
10
+ 'esc-dark',
11
+ computed(() => false),
12
+ );
13
+ const activeTab = ref(null);
14
+
15
+ const tabs = computed(() => {
16
+ const list = [
17
+ { key: null, label: 'All Tickets', filter: {} },
18
+ { key: 'assigned_to_me', label: 'Assigned to Me', filter: { assigned_to: props.currentUserId } },
19
+ { key: 'unassigned', label: 'Unassigned', filter: { unassigned: true } },
20
+ { key: 'urgent', label: 'Urgent', filter: { priority: 'urgent' } },
21
+ { key: 'sla_breaching', label: 'SLA Breaching', filter: { sla_breached: true } },
22
+ { key: 'following', label: 'Following', filter: { following: true } },
23
+ ];
24
+ if (props.currentUserId) return list;
25
+ return list.filter((t) => t.key !== 'assigned_to_me');
26
+ });
27
+
28
+ function selectTab(tab) {
29
+ activeTab.value = tab.key;
30
+ emit('filter', tab.filter);
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <div
36
+ :class="['flex items-center gap-0 border-b', escDark ? 'border-white/[0.08]' : 'border-gray-200']"
37
+ role="tablist"
38
+ aria-label="Ticket view tabs"
39
+ >
40
+ <button
41
+ v-for="tab in tabs"
42
+ :key="tab.key"
43
+ role="tab"
44
+ :aria-selected="activeTab === tab.key"
45
+ :class="[
46
+ 'relative px-4 py-2.5 text-sm font-medium transition-colors whitespace-nowrap',
47
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-0',
48
+ activeTab === tab.key
49
+ ? escDark
50
+ ? 'text-cyan-400 focus-visible:ring-cyan-500/40'
51
+ : 'text-blue-600 focus-visible:ring-blue-500/40'
52
+ : escDark
53
+ ? 'text-neutral-500 hover:text-neutral-300 focus-visible:ring-cyan-500/40'
54
+ : 'text-gray-500 hover:text-gray-700 focus-visible:ring-blue-500/40',
55
+ ]"
56
+ @click="selectTab(tab)"
57
+ >
58
+ {{ tab.label }}
59
+ <span
60
+ v-if="activeTab === tab.key"
61
+ :class="['absolute bottom-0 left-0 right-0 h-0.5 rounded-t', escDark ? 'bg-cyan-400' : 'bg-blue-600']"
62
+ ></span>
63
+ </button>
64
+ </div>
65
+ </template>