@escalated-dev/escalated 0.5.0 → 0.7.0

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 (136) hide show
  1. package/README.md +102 -0
  2. package/package.json +37 -6
  3. package/src/components/ActivityTimeline.stories.js +55 -0
  4. package/src/components/ActivityTimeline.vue +83 -66
  5. package/src/components/AdminDashboard.stories.js +1095 -0
  6. package/src/components/AgentLoadIndicator.stories.js +37 -0
  7. package/src/components/AgentLoadIndicator.vue +49 -0
  8. package/src/components/AgentPerformanceCard.vue +44 -0
  9. package/src/components/ArticleEditor.vue +82 -0
  10. package/src/components/AssigneeSelect.vue +37 -23
  11. package/src/components/AttachmentList.vue +53 -37
  12. package/src/components/AuditLogEntry.vue +95 -0
  13. package/src/components/BulkActionBar.vue +99 -30
  14. package/src/components/CategoryTree.vue +89 -0
  15. package/src/components/ChartWidget.vue +46 -0
  16. package/src/components/CollisionWarning.stories.js +30 -0
  17. package/src/components/CollisionWarning.vue +68 -0
  18. package/src/components/ConditionalFieldRules.vue +135 -0
  19. package/src/components/ContextPanel.vue +66 -0
  20. package/src/components/ContextPanelSection.vue +65 -0
  21. package/src/components/CustomFieldRenderer.vue +159 -0
  22. package/src/components/EscalatedLayout.vue +400 -69
  23. package/src/components/FileDropzone.vue +57 -41
  24. package/src/components/FollowButton.stories.js +30 -0
  25. package/src/components/FollowButton.vue +53 -28
  26. package/src/components/KeyboardShortcutHelp.vue +129 -39
  27. package/src/components/KnowledgePanel.vue +122 -0
  28. package/src/components/KpiCard.stories.js +32 -0
  29. package/src/components/KpiCard.vue +88 -0
  30. package/src/components/MacroDropdown.vue +95 -35
  31. package/src/components/PermissionMatrix.vue +107 -0
  32. package/src/components/PinnedNotes.vue +75 -33
  33. package/src/components/PresenceIndicator.vue +93 -27
  34. package/src/components/PriorityBadge.stories.js +33 -0
  35. package/src/components/PriorityBadge.vue +46 -39
  36. package/src/components/QuickFilters.vue +65 -49
  37. package/src/components/ReplyComposer.vue +273 -159
  38. package/src/components/ReplyThread.vue +78 -23
  39. package/src/components/SatisfactionRating.vue +98 -46
  40. package/src/components/ScheduleEditor.vue +104 -0
  41. package/src/components/SideConversation.vue +165 -0
  42. package/src/components/SideConversationList.vue +147 -0
  43. package/src/components/SkillTagManager.vue +79 -0
  44. package/src/components/SlaComplianceChart.vue +53 -0
  45. package/src/components/SlaTimer.stories.js +35 -0
  46. package/src/components/SlaTimer.vue +62 -49
  47. package/src/components/StatsCard.stories.js +24 -0
  48. package/src/components/StatsCard.vue +58 -46
  49. package/src/components/StatusBadge.stories.js +53 -0
  50. package/src/components/StatusBadge.vue +78 -46
  51. package/src/components/TagSelect.vue +84 -53
  52. package/src/components/TicketFilters.vue +225 -69
  53. package/src/components/TicketLinkPanel.vue +193 -0
  54. package/src/components/TicketList.vue +594 -193
  55. package/src/components/TicketMergeDialog.vue +157 -0
  56. package/src/components/TicketSidebar.vue +167 -99
  57. package/src/components/TicketTypeSelector.vue +21 -0
  58. package/src/components/TotpInput.stories.js +9 -0
  59. package/src/components/TotpInput.vue +81 -0
  60. package/src/components/TwoFactorSetup.vue +113 -0
  61. package/src/composables/useDebouncedSearch.js +38 -0
  62. package/src/composables/useI18n.js +80 -0
  63. package/src/composables/usePluginExtensions.js +95 -3
  64. package/src/composables/usePluginHooks.js +65 -0
  65. package/src/defineEscalatedPlugin.js +34 -8
  66. package/src/index.js +72 -35
  67. package/src/locales/de.json +442 -0
  68. package/src/locales/en.json +467 -0
  69. package/src/locales/es.json +442 -0
  70. package/src/locales/fr.json +442 -0
  71. package/src/locales/index.js +6 -0
  72. package/src/pages/Admin/ApiTokens/Index.vue +231 -67
  73. package/src/pages/Admin/AuditLog/Index.vue +154 -0
  74. package/src/pages/Admin/Automations/Form.vue +184 -0
  75. package/src/pages/Admin/Automations/Index.vue +129 -0
  76. package/src/pages/Admin/BusinessHours/Form.vue +172 -0
  77. package/src/pages/Admin/BusinessHours/Index.vue +113 -0
  78. package/src/pages/Admin/CannedResponses/Index.vue +127 -70
  79. package/src/pages/Admin/Capacity/Index.vue +134 -0
  80. package/src/pages/Admin/CustomFields/Form.vue +195 -0
  81. package/src/pages/Admin/CustomFields/Index.vue +134 -0
  82. package/src/pages/Admin/CustomObjects/Form.vue +202 -0
  83. package/src/pages/Admin/CustomObjects/Index.vue +127 -0
  84. package/src/pages/Admin/CustomObjects/Records.vue +228 -0
  85. package/src/pages/Admin/Departments/Form.vue +75 -51
  86. package/src/pages/Admin/Departments/Index.vue +109 -57
  87. package/src/pages/Admin/EscalationRules/Form.vue +157 -96
  88. package/src/pages/Admin/EscalationRules/Index.vue +178 -57
  89. package/src/pages/Admin/Import/Connect.vue +96 -0
  90. package/src/pages/Admin/Import/Index.vue +102 -0
  91. package/src/pages/Admin/Import/Mapping.vue +110 -0
  92. package/src/pages/Admin/Import/Progress.vue +153 -0
  93. package/src/pages/Admin/Import/Review.vue +76 -0
  94. package/src/pages/Admin/KnowledgeBase/Articles/Form.vue +142 -0
  95. package/src/pages/Admin/KnowledgeBase/Articles/Index.vue +195 -0
  96. package/src/pages/Admin/KnowledgeBase/Categories/Index.vue +193 -0
  97. package/src/pages/Admin/Macros/Index.vue +250 -89
  98. package/src/pages/Admin/Plugins/Index.vue +205 -75
  99. package/src/pages/Admin/Reports/AgentMetrics.vue +115 -0
  100. package/src/pages/Admin/Reports/CsatReport.vue +127 -0
  101. package/src/pages/Admin/Reports/Dashboard.vue +211 -0
  102. package/src/pages/Admin/Reports/SlaReport.vue +166 -0
  103. package/src/pages/Admin/Reports.vue +139 -34
  104. package/src/pages/Admin/Roles/Form.vue +71 -0
  105. package/src/pages/Admin/Roles/Index.vue +122 -0
  106. package/src/pages/Admin/Settings/CsatSettings.vue +165 -0
  107. package/src/pages/Admin/Settings/DataRetention.vue +182 -0
  108. package/src/pages/Admin/Settings/EmailSettings.vue +243 -0
  109. package/src/pages/Admin/Settings/Sandbox.vue +135 -0
  110. package/src/pages/Admin/Settings/SsoSettings.vue +218 -0
  111. package/src/pages/Admin/Settings/TwoFactor.vue +110 -0
  112. package/src/pages/Admin/Settings.vue +468 -260
  113. package/src/pages/Admin/Skills/Form.vue +53 -0
  114. package/src/pages/Admin/Skills/Index.vue +94 -0
  115. package/src/pages/Admin/SlaPolicies/Form.vue +118 -75
  116. package/src/pages/Admin/SlaPolicies/Index.vue +120 -59
  117. package/src/pages/Admin/Statuses/Form.vue +114 -0
  118. package/src/pages/Admin/Statuses/Index.vue +153 -0
  119. package/src/pages/Admin/Tags/Index.vue +191 -105
  120. package/src/pages/Admin/Tickets/Index.vue +91 -39
  121. package/src/pages/Admin/Tickets/Show.vue +101 -37
  122. package/src/pages/Admin/Webhooks/DeliveryLog.vue +120 -0
  123. package/src/pages/Admin/Webhooks/Form.vue +110 -0
  124. package/src/pages/Admin/Webhooks/Index.vue +136 -0
  125. package/src/pages/Agent/Dashboard.vue +311 -186
  126. package/src/pages/Agent/TicketIndex.vue +88 -38
  127. package/src/pages/Agent/TicketShow.vue +258 -51
  128. package/src/pages/Customer/Create.vue +36 -9
  129. package/src/pages/Customer/Index.vue +31 -24
  130. package/src/pages/Customer/KnowledgeBase/Article.vue +127 -0
  131. package/src/pages/Customer/KnowledgeBase/Index.vue +151 -0
  132. package/src/plugin.js +86 -1
  133. package/src/utils/constants.js +30 -0
  134. package/src/utils/formatting.js +35 -0
  135. package/src/utils/sanitizeHtml.js +64 -0
  136. package/src/pages/Admin/Plugins.vue +0 -129
@@ -0,0 +1,37 @@
1
+ import AgentLoadIndicator from './AgentLoadIndicator.vue';
2
+
3
+ export default {
4
+ title: 'Components/AgentLoadIndicator',
5
+ component: AgentLoadIndicator,
6
+ };
7
+
8
+ export const Low = { args: { current: 3, max: 20 } };
9
+ export const Medium = { args: { current: 15, max: 20 } };
10
+ export const High = { args: { current: 18, max: 20 } };
11
+ export const Full = { args: { current: 20, max: 20 } };
12
+
13
+ export const AllLevels = {
14
+ render: () => ({
15
+ components: { AgentLoadIndicator },
16
+ template: `
17
+ <div class="space-y-3" style="max-width: 200px;">
18
+ <div class="flex items-center justify-between">
19
+ <span class="text-xs text-neutral-400">Light</span>
20
+ <AgentLoadIndicator :current="3" :max="20" />
21
+ </div>
22
+ <div class="flex items-center justify-between">
23
+ <span class="text-xs text-neutral-400">Moderate</span>
24
+ <AgentLoadIndicator :current="12" :max="20" />
25
+ </div>
26
+ <div class="flex items-center justify-between">
27
+ <span class="text-xs text-neutral-400">Heavy</span>
28
+ <AgentLoadIndicator :current="16" :max="20" />
29
+ </div>
30
+ <div class="flex items-center justify-between">
31
+ <span class="text-xs text-neutral-400">At Capacity</span>
32
+ <AgentLoadIndicator :current="20" :max="20" />
33
+ </div>
34
+ </div>
35
+ `,
36
+ }),
37
+ };
@@ -0,0 +1,49 @@
1
+ <script setup>
2
+ import { computed, inject } from 'vue';
3
+
4
+ const props = defineProps({
5
+ current: { type: Number, required: true },
6
+ max: { type: Number, required: true },
7
+ });
8
+
9
+ const escDark = inject(
10
+ 'esc-dark',
11
+ computed(() => false),
12
+ );
13
+
14
+ const percentage = computed(() => {
15
+ if (props.max <= 0) return 100;
16
+ return Math.round((props.current / props.max) * 100);
17
+ });
18
+
19
+ const colorClass = computed(() => {
20
+ if (percentage.value >= 90) return 'bg-rose-500';
21
+ if (percentage.value >= 70) return 'bg-amber-500';
22
+ return 'bg-emerald-500';
23
+ });
24
+
25
+ const textColorClass = computed(() => {
26
+ if (percentage.value >= 90) return escDark.value ? 'text-rose-400' : 'text-rose-600';
27
+ if (percentage.value >= 70) return escDark.value ? 'text-amber-400' : 'text-amber-600';
28
+ return escDark.value ? 'text-emerald-400' : 'text-emerald-600';
29
+ });
30
+ </script>
31
+
32
+ <template>
33
+ <div
34
+ class="flex items-center gap-2"
35
+ role="meter"
36
+ :aria-valuenow="percentage"
37
+ aria-valuemin="0"
38
+ aria-valuemax="100"
39
+ aria-label="Agent workload"
40
+ >
41
+ <div :class="['h-2 w-20 overflow-hidden rounded-full', escDark ? 'bg-white/[0.06]' : 'bg-gray-200']">
42
+ <div
43
+ :class="['h-full rounded-full transition-all', colorClass]"
44
+ :style="{ width: Math.min(percentage, 100) + '%' }"
45
+ ></div>
46
+ </div>
47
+ <span :class="['text-xs font-medium', textColorClass]">{{ current }}/{{ max }}</span>
48
+ </div>
49
+ </template>
@@ -0,0 +1,44 @@
1
+ <script setup>
2
+ defineProps({
3
+ agent: { type: Object, required: true },
4
+ });
5
+ </script>
6
+
7
+ <template>
8
+ <div class="rounded-xl border border-white/[0.06] bg-neutral-900/60 p-5">
9
+ <div class="mb-4 flex items-center gap-3">
10
+ <div
11
+ class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-500/20 to-violet-500/20 text-sm font-bold text-white"
12
+ >
13
+ {{ agent.agent_name?.charAt(0)?.toUpperCase() || '?' }}
14
+ </div>
15
+ <div>
16
+ <div class="text-sm font-semibold text-neutral-200">{{ agent.agent_name }}</div>
17
+ <div class="text-xs text-neutral-500">Agent</div>
18
+ </div>
19
+ </div>
20
+
21
+ <div class="grid grid-cols-2 gap-3">
22
+ <div class="rounded-lg bg-white/[0.03] p-3">
23
+ <div class="text-[11px] font-medium uppercase tracking-wider text-neutral-600">Total Tickets</div>
24
+ <div class="mt-1 text-lg font-bold text-white">{{ agent.total_tickets }}</div>
25
+ </div>
26
+ <div class="rounded-lg bg-white/[0.03] p-3">
27
+ <div class="text-[11px] font-medium uppercase tracking-wider text-neutral-600">Resolved</div>
28
+ <div class="mt-1 text-lg font-bold text-emerald-400">{{ agent.resolved_tickets }}</div>
29
+ </div>
30
+ <div class="rounded-lg bg-white/[0.03] p-3">
31
+ <div class="text-[11px] font-medium uppercase tracking-wider text-neutral-600">Avg Response</div>
32
+ <div class="mt-1 text-lg font-bold text-white">
33
+ {{ agent.avg_response_hours ?? '—' }}<span class="text-xs text-neutral-500">h</span>
34
+ </div>
35
+ </div>
36
+ <div class="rounded-lg bg-white/[0.03] p-3">
37
+ <div class="text-[11px] font-medium uppercase tracking-wider text-neutral-600">Avg Resolution</div>
38
+ <div class="mt-1 text-lg font-bold text-white">
39
+ {{ agent.avg_resolution_hours ?? '—' }}<span class="text-xs text-neutral-500">h</span>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </template>
@@ -0,0 +1,82 @@
1
+ <script setup>
2
+ import { ref } from 'vue';
3
+
4
+ const props = defineProps({
5
+ modelValue: { type: String, default: '' },
6
+ });
7
+
8
+ const emit = defineEmits(['update:modelValue']);
9
+ const textarea = ref(null);
10
+
11
+ function insertMarkdown(before, after = '') {
12
+ const el = textarea.value;
13
+ if (!el) return;
14
+
15
+ const start = el.selectionStart;
16
+ const end = el.selectionEnd;
17
+ const text = props.modelValue;
18
+ const selected = text.substring(start, end);
19
+ const replacement = before + (selected || 'text') + after;
20
+ const newValue = text.substring(0, start) + replacement + text.substring(end);
21
+
22
+ emit('update:modelValue', newValue);
23
+
24
+ requestAnimationFrame(() => {
25
+ el.focus();
26
+ const cursorPos = start + before.length + (selected ? selected.length : 4);
27
+ el.setSelectionRange(cursorPos, cursorPos);
28
+ });
29
+ }
30
+
31
+ function insertBold() {
32
+ insertMarkdown('**', '**');
33
+ }
34
+ function insertItalic() {
35
+ insertMarkdown('*', '*');
36
+ }
37
+ function insertLink() {
38
+ insertMarkdown('[', '](url)');
39
+ }
40
+ function insertHeading() {
41
+ insertMarkdown('## ');
42
+ }
43
+ function insertList() {
44
+ insertMarkdown('- ');
45
+ }
46
+
47
+ const buttons = [
48
+ { label: 'Bold', action: insertBold, icon: 'B', cls: 'font-bold' },
49
+ { label: 'Italic', action: insertItalic, icon: 'I', cls: 'italic' },
50
+ { label: 'Link', action: insertLink, icon: 'Link', cls: '' },
51
+ { label: 'Heading', action: insertHeading, icon: 'H', cls: 'font-bold' },
52
+ { label: 'List', action: insertList, icon: 'List', cls: '' },
53
+ ];
54
+ </script>
55
+
56
+ <template>
57
+ <div>
58
+ <div class="flex gap-1 rounded-t-lg border border-white/[0.06] bg-neutral-800/60 px-2 py-1.5">
59
+ <button
60
+ v-for="btn in buttons"
61
+ :key="btn.label"
62
+ type="button"
63
+ :title="btn.label"
64
+ :class="[
65
+ 'rounded px-2.5 py-1 text-xs text-neutral-400 transition-colors hover:bg-white/[0.08] hover:text-neutral-200',
66
+ btn.cls,
67
+ ]"
68
+ @click="btn.action"
69
+ >
70
+ {{ btn.icon }}
71
+ </button>
72
+ </div>
73
+ <textarea
74
+ ref="textarea"
75
+ :value="modelValue"
76
+ class="block w-full rounded-b-lg border border-t-0 border-white/[0.06] bg-white/[0.03] px-3 py-3 text-sm text-neutral-200 placeholder-neutral-600 focus:border-cyan-500/40 focus:outline-none focus:ring-1 focus:ring-cyan-500/40"
77
+ rows="16"
78
+ placeholder="Write your article content using Markdown..."
79
+ @input="emit('update:modelValue', $event.target.value)"
80
+ ></textarea>
81
+ </div>
82
+ </template>
@@ -1,23 +1,37 @@
1
- <script setup>
2
- import { computed, inject } from 'vue';
3
-
4
- defineProps({
5
- agents: { type: Array, required: true },
6
- modelValue: { type: [Number, String], default: null },
7
- });
8
-
9
- const emit = defineEmits(['update:modelValue']);
10
- const dark = inject('esc-dark', computed(() => false));
11
- </script>
12
-
13
- <template>
14
- <div>
15
- <label :class="['mb-1 block text-xs font-medium', dark ? 'text-neutral-500' : 'text-gray-600']">Assigned To</label>
16
- <select :value="modelValue" @change="emit('update:modelValue', $event.target.value || null)"
17
- :class="['w-full rounded-md border px-2 py-1.5 text-sm focus:outline-none',
18
- dark ? 'border-white/10 bg-neutral-950 text-neutral-200 focus:border-white/20 focus:ring-1 focus:ring-white/10' : 'border-gray-300 focus:border-blue-500']">
19
- <option value="">Unassigned</option>
20
- <option v-for="agent in agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option>
21
- </select>
22
- </div>
23
- </template>
1
+ <script setup>
2
+ import { computed, inject } from 'vue';
3
+
4
+ defineProps({
5
+ agents: { type: Array, required: true },
6
+ modelValue: { type: [Number, String], default: null },
7
+ });
8
+
9
+ const emit = defineEmits(['update:modelValue']);
10
+ const dark = inject(
11
+ 'esc-dark',
12
+ computed(() => false),
13
+ );
14
+ </script>
15
+
16
+ <template>
17
+ <div>
18
+ <label
19
+ :class="['mb-1 block text-xs font-medium', dark ? 'text-[var(--esc-panel-text-muted)]' : 'text-gray-600']"
20
+ >Assigned To</label
21
+ >
22
+ <select
23
+ :value="modelValue"
24
+ aria-label="Assign to agent"
25
+ :class="[
26
+ 'w-full rounded-md border px-2 py-1.5 text-sm focus:outline-none',
27
+ dark
28
+ ? 'border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)] text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-border-input)] focus:ring-1 focus:ring-[var(--esc-panel-border-input)]'
29
+ : 'border-gray-300 focus:border-blue-500',
30
+ ]"
31
+ @change="emit('update:modelValue', $event.target.value || null)"
32
+ >
33
+ <option value="">Unassigned</option>
34
+ <option v-for="agent in agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option>
35
+ </select>
36
+ </div>
37
+ </template>
@@ -1,37 +1,53 @@
1
- <script setup>
2
- import { computed, inject } from 'vue';
3
-
4
- defineProps({
5
- attachments: { type: Array, required: true },
6
- });
7
-
8
- const dark = inject('esc-dark', computed(() => false));
9
-
10
- function formatSize(bytes) {
11
- if (bytes < 1024) return bytes + ' B';
12
- if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
13
- return (bytes / 1048576).toFixed(1) + ' MB';
14
- }
15
-
16
- function iconForMime(mime) {
17
- if (mime?.startsWith('image/')) return '🖼️';
18
- if (mime?.startsWith('video/')) return '🎬';
19
- if (mime === 'application/pdf') return '📄';
20
- return '📎';
21
- }
22
- </script>
23
-
24
- <template>
25
- <div class="space-y-1">
26
- <div v-for="attachment in attachments" :key="attachment.id"
27
- :class="['flex items-center gap-2 rounded-lg border px-3 py-2 text-sm',
28
- dark ? 'border-white/[0.06] bg-white/[0.03]' : 'border-gray-200 bg-gray-50']">
29
- <span>{{ iconForMime(attachment.mime_type) }}</span>
30
- <a :href="attachment.url" target="_blank"
31
- :class="['flex-1 truncate font-medium hover:underline', dark ? 'text-white' : 'text-blue-600']">
32
- {{ attachment.original_filename }}
33
- </a>
34
- <span :class="['text-xs', dark ? 'text-neutral-500' : 'text-gray-400']">{{ formatSize(attachment.size) }}</span>
35
- </div>
36
- </div>
37
- </template>
1
+ <script setup>
2
+ import { computed, inject } from 'vue';
3
+
4
+ defineProps({
5
+ attachments: { type: Array, required: true },
6
+ });
7
+
8
+ const dark = inject(
9
+ 'esc-dark',
10
+ computed(() => false),
11
+ );
12
+
13
+ function formatSize(bytes) {
14
+ if (bytes < 1024) return bytes + ' B';
15
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
16
+ return (bytes / 1048576).toFixed(1) + ' MB';
17
+ }
18
+
19
+ function iconForMime(mime) {
20
+ if (mime?.startsWith('image/')) return '🖼️';
21
+ if (mime?.startsWith('video/')) return '🎬';
22
+ if (mime === 'application/pdf') return '📄';
23
+ return '📎';
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <div class="space-y-1">
29
+ <div
30
+ v-for="attachment in attachments"
31
+ :key="attachment.id"
32
+ :class="[
33
+ 'flex items-center gap-2 rounded-lg border px-3 py-2 text-sm',
34
+ dark ? 'border-[var(--esc-panel-border)] bg-[var(--esc-panel-hover)]' : 'border-gray-200 bg-gray-50',
35
+ ]"
36
+ >
37
+ <span>{{ iconForMime(attachment.mime_type) }}</span>
38
+ <a
39
+ :href="attachment.url"
40
+ target="_blank"
41
+ :class="[
42
+ 'flex-1 truncate font-medium hover:underline',
43
+ dark ? 'text-[var(--esc-panel-text)]' : 'text-blue-600',
44
+ ]"
45
+ >
46
+ {{ attachment.original_filename }}
47
+ </a>
48
+ <span :class="['text-xs', dark ? 'text-[var(--esc-panel-text-muted)]' : 'text-gray-400']">{{
49
+ formatSize(attachment.size)
50
+ }}</span>
51
+ </div>
52
+ </div>
53
+ </template>
@@ -0,0 +1,95 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+
4
+ const props = defineProps({
5
+ log: { type: Object, required: true },
6
+ });
7
+
8
+ const actionColors = {
9
+ created: 'bg-emerald-500/10 text-emerald-400 ring-1 ring-emerald-500/20',
10
+ updated: 'bg-cyan-500/10 text-cyan-400 ring-1 ring-cyan-500/20',
11
+ deleted: 'bg-rose-500/10 text-rose-400 ring-1 ring-rose-500/20',
12
+ };
13
+
14
+ const actionColor = computed(
15
+ () => actionColors[props.log.action] || 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20',
16
+ );
17
+
18
+ const userInitial = computed(() => {
19
+ const name = props.log.user?.name || 'System';
20
+ return name.charAt(0).toUpperCase();
21
+ });
22
+
23
+ const resourceName = computed(() => {
24
+ const type = props.log.auditable_type || '';
25
+ return type.split('\\').pop() || type;
26
+ });
27
+
28
+ const changedFields = computed(() => {
29
+ if (props.log.action !== 'updated') return [];
30
+ const oldVals = props.log.old_values || {};
31
+ const newVals = props.log.new_values || {};
32
+ const keys = new Set([...Object.keys(oldVals), ...Object.keys(newVals)]);
33
+ return Array.from(keys).map((key) => ({
34
+ field: key,
35
+ old: oldVals[key] ?? '-',
36
+ new: newVals[key] ?? '-',
37
+ }));
38
+ });
39
+
40
+ const formattedDate = computed(() => {
41
+ if (!props.log.created_at) return '';
42
+ const d = new Date(props.log.created_at);
43
+ return d.toLocaleString();
44
+ });
45
+ </script>
46
+
47
+ <template>
48
+ <div
49
+ class="flex gap-3 rounded-lg border border-white/[0.04] bg-white/[0.02] px-4 py-3 transition-colors hover:bg-white/[0.03]"
50
+ >
51
+ <!-- Avatar -->
52
+ <div
53
+ class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-cyan-500/20 to-violet-500/20 text-xs font-semibold text-neutral-300"
54
+ >
55
+ {{ userInitial }}
56
+ </div>
57
+
58
+ <div class="min-w-0 flex-1">
59
+ <div class="flex items-center gap-2 text-sm">
60
+ <span class="font-medium text-neutral-200">{{ log.user?.name || 'System' }}</span>
61
+ <span :class="['inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium', actionColor]">
62
+ {{ log.action }}
63
+ </span>
64
+ <span class="text-neutral-400">{{ resourceName }}</span>
65
+ <span class="text-neutral-600">#{{ log.auditable_id }}</span>
66
+ <span class="ml-auto text-xs text-neutral-500">{{ formattedDate }}</span>
67
+ </div>
68
+
69
+ <!-- Changed fields diff -->
70
+ <div v-if="changedFields.length" class="mt-2 space-y-1">
71
+ <div v-for="change in changedFields" :key="change.field" class="flex items-center gap-2 text-xs">
72
+ <span class="font-medium text-neutral-400">{{ change.field }}:</span>
73
+ <span class="text-rose-400/70 line-through">{{
74
+ typeof change.old === 'object' ? JSON.stringify(change.old) : change.old
75
+ }}</span>
76
+ <svg
77
+ class="h-3 w-3 text-neutral-600"
78
+ fill="none"
79
+ stroke="currentColor"
80
+ stroke-width="2"
81
+ viewBox="0 0 24 24"
82
+ >
83
+ <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
84
+ </svg>
85
+ <span class="text-emerald-400/70">{{
86
+ typeof change.new === 'object' ? JSON.stringify(change.new) : change.new
87
+ }}</span>
88
+ </div>
89
+ </div>
90
+
91
+ <!-- IP address -->
92
+ <div v-if="log.ip_address" class="mt-1 text-xs text-neutral-600">IP: {{ log.ip_address }}</div>
93
+ </div>
94
+ </div>
95
+ </template>
@@ -10,21 +10,30 @@ const props = defineProps({
10
10
  });
11
11
 
12
12
  const emit = defineEmits(['clear']);
13
- const escDark = inject('esc-dark', computed(() => false));
13
+ const escDark = inject(
14
+ 'esc-dark',
15
+ computed(() => false),
16
+ );
14
17
  const processing = ref(false);
15
18
 
16
19
  function performAction(action, value) {
17
20
  if (!props.selectedIds.length || processing.value) return;
18
21
  processing.value = true;
19
- router.post(props.bulkRoute, {
20
- ticket_ids: props.selectedIds,
21
- action,
22
- value,
23
- }, {
24
- preserveScroll: true,
25
- onSuccess: () => emit('clear'),
26
- onFinish: () => { processing.value = false; },
27
- });
22
+ router.post(
23
+ props.bulkRoute,
24
+ {
25
+ ticket_ids: props.selectedIds,
26
+ action,
27
+ value,
28
+ },
29
+ {
30
+ preserveScroll: true,
31
+ onSuccess: () => emit('clear'),
32
+ onFinish: () => {
33
+ processing.value = false;
34
+ },
35
+ },
36
+ );
28
37
  }
29
38
 
30
39
  const statusOptions = [
@@ -42,53 +51,113 @@ const priorityOptions = [
42
51
  { value: 'critical', label: 'Critical' },
43
52
  ];
44
53
 
45
- const selectClass = computed(() => escDark.value
46
- ? 'rounded-lg border border-white/10 bg-neutral-950 px-2 py-1.5 text-sm text-neutral-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10'
47
- : 'rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none'
54
+ const selectClass = computed(() =>
55
+ escDark.value
56
+ ? 'rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)] px-2 py-1.5 text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-border-input)] focus:outline-none focus:ring-1 focus:ring-[var(--esc-panel-border-input)]'
57
+ : 'rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none',
48
58
  );
49
59
  </script>
50
60
 
51
61
  <template>
52
- <Transition enter-active-class="transition-all duration-200 ease-out" enter-from-class="translate-y-full opacity-0" enter-to-class="translate-y-0 opacity-100"
53
- leave-active-class="transition-all duration-150 ease-in" leave-from-class="translate-y-0 opacity-100" leave-to-class="translate-y-full opacity-0">
54
- <div v-if="selectedIds.length > 0"
55
- :class="['fixed bottom-0 left-0 right-0 z-50 border-t px-6 py-3',
56
- escDark ? 'border-white/[0.06] bg-neutral-950/95 backdrop-blur-xl' : 'border-gray-200 bg-white/95 backdrop-blur-xl shadow-lg']">
62
+ <Transition
63
+ enter-active-class="transition-all duration-200 ease-out"
64
+ enter-from-class="translate-y-full opacity-0"
65
+ enter-to-class="translate-y-0 opacity-100"
66
+ leave-active-class="transition-all duration-150 ease-in"
67
+ leave-from-class="translate-y-0 opacity-100"
68
+ leave-to-class="translate-y-full opacity-0"
69
+ >
70
+ <div
71
+ v-if="selectedIds.length > 0"
72
+ :class="[
73
+ 'fixed bottom-0 left-0 right-0 z-50 border-t px-6 py-3',
74
+ escDark
75
+ ? 'border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface-alt)]/95 backdrop-blur-xl'
76
+ : 'border-gray-200 bg-white/95 backdrop-blur-xl shadow-lg',
77
+ ]"
78
+ >
57
79
  <div class="mx-auto flex max-w-7xl items-center gap-4">
58
- <span :class="['text-sm font-semibold', escDark ? 'text-white' : 'text-gray-900']">
80
+ <span
81
+ :class="['text-sm font-semibold', escDark ? 'text-[var(--esc-panel-text)]' : 'text-gray-900']"
82
+ aria-live="polite"
83
+ >
59
84
  {{ selectedIds.length }} ticket{{ selectedIds.length !== 1 ? 's' : '' }} selected
60
85
  </span>
61
86
 
62
- <select @change="performAction('status', $event.target.value); $event.target.value = ''" :class="selectClass" :disabled="processing">
87
+ <select
88
+ :class="selectClass"
89
+ :disabled="processing"
90
+ aria-label="Change status"
91
+ @change="
92
+ performAction('status', $event.target.value);
93
+ $event.target.value = '';
94
+ "
95
+ >
63
96
  <option value="">Status...</option>
64
97
  <option v-for="o in statusOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
65
98
  </select>
66
99
 
67
- <select @change="performAction('priority', $event.target.value); $event.target.value = ''" :class="selectClass" :disabled="processing">
100
+ <select
101
+ :class="selectClass"
102
+ :disabled="processing"
103
+ aria-label="Change priority"
104
+ @change="
105
+ performAction('priority', $event.target.value);
106
+ $event.target.value = '';
107
+ "
108
+ >
68
109
  <option value="">Priority...</option>
69
110
  <option v-for="o in priorityOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
70
111
  </select>
71
112
 
72
- <select v-if="agents.length" @change="performAction('assign', $event.target.value); $event.target.value = ''" :class="selectClass" :disabled="processing">
113
+ <select
114
+ v-if="agents.length"
115
+ :class="selectClass"
116
+ :disabled="processing"
117
+ @change="
118
+ performAction('assign', $event.target.value);
119
+ $event.target.value = '';
120
+ "
121
+ >
73
122
  <option value="">Assign to...</option>
74
123
  <option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
75
124
  </select>
76
125
 
77
- <select v-if="departments.length" @change="performAction('department', $event.target.value); $event.target.value = ''" :class="selectClass" :disabled="processing">
126
+ <select
127
+ v-if="departments.length"
128
+ :class="selectClass"
129
+ :disabled="processing"
130
+ @change="
131
+ performAction('department', $event.target.value);
132
+ $event.target.value = '';
133
+ "
134
+ >
78
135
  <option value="">Department...</option>
79
136
  <option v-for="d in departments" :key="d.id" :value="d.id">{{ d.name }}</option>
80
137
  </select>
81
138
 
82
- <button @click="performAction('delete', null)"
83
- :class="['rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
84
- escDark ? 'bg-rose-500/15 text-rose-400 ring-1 ring-rose-500/20 hover:bg-rose-500/25' : 'bg-red-50 text-red-600 ring-1 ring-red-200 hover:bg-red-100']"
85
- :disabled="processing">
139
+ <button
140
+ :class="[
141
+ 'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
142
+ escDark
143
+ ? 'bg-rose-500/15 text-rose-400 ring-1 ring-rose-500/20 hover:bg-rose-500/25'
144
+ : 'bg-red-50 text-red-600 ring-1 ring-red-200 hover:bg-red-100',
145
+ ]"
146
+ :disabled="processing"
147
+ @click="performAction('delete', null)"
148
+ >
86
149
  Delete
87
150
  </button>
88
151
 
89
- <button @click="emit('clear')"
90
- :class="['ml-auto rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
91
- escDark ? 'text-neutral-400 hover:bg-white/[0.04] hover:text-neutral-200' : 'text-gray-500 hover:bg-gray-100 hover:text-gray-700']">
152
+ <button
153
+ :class="[
154
+ 'ml-auto rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
155
+ escDark
156
+ ? 'text-[var(--esc-panel-text-tertiary)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]'
157
+ : 'text-gray-500 hover:bg-gray-100 hover:text-gray-700',
158
+ ]"
159
+ @click="emit('clear')"
160
+ >
92
161
  Deselect All
93
162
  </button>
94
163
  </div>