@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
@@ -0,0 +1,89 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+
4
+ const props = defineProps({
5
+ label: { type: String, required: true },
6
+ value: { type: [Number, String], required: true },
7
+ previousValue: { type: [Number, String], default: null },
8
+ format: { type: String, default: '' },
9
+ icon: { type: String, default: null },
10
+ invertColor: { type: Boolean, default: false },
11
+ });
12
+
13
+ const percentChange = computed(() => {
14
+ if (props.previousValue === null || props.previousValue === 0) return null;
15
+ const current = Number(props.value) || 0;
16
+ const previous = Number(props.previousValue) || 0;
17
+ if (previous === 0) return null;
18
+ return Math.round(((current - previous) / previous) * 100);
19
+ });
20
+
21
+ const changeDirection = computed(() => {
22
+ if (percentChange.value === null || percentChange.value === 0) return 'neutral';
23
+ return percentChange.value > 0 ? 'up' : 'down';
24
+ });
25
+
26
+ const changeColor = computed(() => {
27
+ if (changeDirection.value === 'neutral') return 'text-[var(--esc-panel-text-muted)]';
28
+ const isPositiveGood = !props.invertColor;
29
+ if (changeDirection.value === 'up') {
30
+ return isPositiveGood ? 'text-emerald-400' : 'text-red-400';
31
+ }
32
+ return isPositiveGood ? 'text-red-400' : 'text-emerald-400';
33
+ });
34
+ </script>
35
+
36
+ <template>
37
+ <div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-5">
38
+ <div class="flex items-start justify-between">
39
+ <div>
40
+ <div class="text-[13px] font-medium text-[var(--esc-panel-text-muted)]">
41
+ {{ label }}
42
+ </div>
43
+ <div class="mt-2 text-2xl font-bold tracking-tight text-[var(--esc-panel-text)]">
44
+ {{ value }}{{ format }}
45
+ </div>
46
+ </div>
47
+ <div v-if="icon" class="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--esc-panel-hover)]">
48
+ <svg
49
+ class="h-5 w-5 text-[var(--esc-panel-text-muted)]"
50
+ fill="none"
51
+ stroke="currentColor"
52
+ stroke-width="1.5"
53
+ viewBox="0 0 24 24"
54
+ >
55
+ <path stroke-linecap="round" stroke-linejoin="round" :d="icon" />
56
+ </svg>
57
+ </div>
58
+ </div>
59
+ <div v-if="previousValue !== null" class="mt-3 flex items-center gap-2">
60
+ <div class="flex items-center gap-1" :class="changeColor">
61
+ <svg
62
+ v-if="changeDirection === 'up'"
63
+ class="h-4 w-4"
64
+ fill="none"
65
+ stroke="currentColor"
66
+ stroke-width="2"
67
+ viewBox="0 0 24 24"
68
+ >
69
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25" />
70
+ </svg>
71
+ <svg
72
+ v-else-if="changeDirection === 'down'"
73
+ class="h-4 w-4"
74
+ fill="none"
75
+ stroke="currentColor"
76
+ stroke-width="2"
77
+ viewBox="0 0 24 24"
78
+ >
79
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 4.5l15 15m0 0V8.25m0 11.25H8.25" />
80
+ </svg>
81
+ <span v-else class="text-sm">--</span>
82
+ <span class="text-xs font-medium">
83
+ {{ percentChange !== null ? `${Math.abs(percentChange)}%` : '--' }}
84
+ </span>
85
+ </div>
86
+ <span class="text-xs text-[var(--esc-panel-text-muted)]"> vs {{ previousValue }}{{ format }} prev </span>
87
+ </div>
88
+ </div>
89
+ </template>
@@ -0,0 +1,67 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+
4
+ const props = defineProps({
5
+ title: { type: String, default: 'Percentile Distribution' },
6
+ percentiles: {
7
+ type: Object,
8
+ default: () => ({ p50: 0, p75: 0, p90: 0, p95: 0, p99: 0 }),
9
+ },
10
+ unit: { type: String, default: 'h' },
11
+ target: { type: Number, default: null },
12
+ });
13
+
14
+ const maxVal = computed(() => {
15
+ const vals = Object.values(props.percentiles);
16
+ return Math.max(...vals, 1);
17
+ });
18
+
19
+ const bars = computed(() => {
20
+ const entries = [
21
+ { key: 'p50', label: 'P50 (Median)', color: '#10b981' },
22
+ { key: 'p75', label: 'P75', color: '#f59e0b' },
23
+ { key: 'p90', label: 'P90', color: '#f97316' },
24
+ { key: 'p95', label: 'P95', color: '#ef4444' },
25
+ { key: 'p99', label: 'P99', color: '#dc2626' },
26
+ ];
27
+ return entries.map((e) => ({
28
+ ...e,
29
+ value: props.percentiles[e.key] || 0,
30
+ width: ((props.percentiles[e.key] || 0) / maxVal.value) * 100,
31
+ }));
32
+ });
33
+ </script>
34
+
35
+ <template>
36
+ <div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-5">
37
+ <h3 v-if="title" class="mb-4 text-sm font-semibold text-[var(--esc-panel-text-secondary)]">{{ title }}</h3>
38
+ <div class="space-y-3">
39
+ <div v-for="bar in bars" :key="bar.key" class="flex items-center gap-3">
40
+ <span class="w-24 shrink-0 text-xs font-medium text-[var(--esc-panel-text-muted)]">
41
+ {{ bar.label }}
42
+ </span>
43
+ <div class="relative flex-1">
44
+ <div class="h-6 overflow-hidden rounded-full bg-[var(--esc-panel-hover)]">
45
+ <div
46
+ class="h-full rounded-full transition-all duration-700 ease-out"
47
+ :style="{ width: `${bar.width}%`, backgroundColor: bar.color }"
48
+ ></div>
49
+ </div>
50
+ <div
51
+ v-if="target !== null"
52
+ class="absolute top-0 h-6 w-0.5 bg-white/40"
53
+ :style="{ left: `${(target / maxVal) * 100}%` }"
54
+ :title="`Target: ${target}${unit}`"
55
+ ></div>
56
+ </div>
57
+ <span class="w-16 text-right text-xs font-bold text-[var(--esc-panel-text)]">
58
+ {{ bar.value }}{{ unit }}
59
+ </span>
60
+ </div>
61
+ </div>
62
+ <div v-if="target !== null" class="mt-3 flex items-center gap-2 text-xs text-[var(--esc-panel-text-muted)]">
63
+ <span class="inline-block h-3 w-0.5 bg-white/40"></span>
64
+ Target: {{ target }}{{ unit }}
65
+ </div>
66
+ </div>
67
+ </template>
@@ -0,0 +1,98 @@
1
+ <script setup>
2
+ import { ref, computed } from 'vue';
3
+
4
+ const props = defineProps({
5
+ modelValue: { type: Number, default: 30 },
6
+ options: { type: Array, default: () => [7, 30, 90, 365] },
7
+ });
8
+
9
+ const emit = defineEmits(['update:modelValue']);
10
+
11
+ const showCustom = ref(false);
12
+ const customStart = ref('');
13
+ const customEnd = ref('');
14
+
15
+ const isCustom = computed(() => !props.options.includes(props.modelValue));
16
+
17
+ function selectPeriod(days) {
18
+ showCustom.value = false;
19
+ emit('update:modelValue', days);
20
+ }
21
+
22
+ function applyCustom() {
23
+ if (customStart.value && customEnd.value) {
24
+ const start = new Date(customStart.value);
25
+ const end = new Date(customEnd.value);
26
+ const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24));
27
+ if (days > 0) {
28
+ emit('update:modelValue', days);
29
+ showCustom.value = false;
30
+ }
31
+ }
32
+ }
33
+
34
+ function formatLabel(days) {
35
+ if (days === 365) return '1y';
36
+ return `${days}d`;
37
+ }
38
+ </script>
39
+
40
+ <template>
41
+ <div class="flex items-center gap-2">
42
+ <button
43
+ v-for="d in options"
44
+ :key="d"
45
+ :class="[
46
+ 'rounded-lg px-3.5 py-1.5 text-sm font-medium transition-all',
47
+ modelValue === d
48
+ ? 'bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] text-white shadow-lg shadow-[var(--esc-panel-bg)]/20'
49
+ : 'border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] text-[var(--esc-panel-text-tertiary)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]',
50
+ ]"
51
+ @click="selectPeriod(d)"
52
+ >
53
+ {{ formatLabel(d) }}
54
+ </button>
55
+ <div class="relative">
56
+ <button
57
+ :class="[
58
+ 'rounded-lg px-3.5 py-1.5 text-sm font-medium transition-all',
59
+ isCustom
60
+ ? 'bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] text-white shadow-lg shadow-[var(--esc-panel-bg)]/20'
61
+ : 'border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] text-[var(--esc-panel-text-tertiary)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]',
62
+ ]"
63
+ @click="showCustom = !showCustom"
64
+ >
65
+ Custom
66
+ </button>
67
+ <div
68
+ v-if="showCustom"
69
+ class="absolute right-0 top-full z-10 mt-2 rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-4 shadow-xl"
70
+ >
71
+ <div class="flex flex-col gap-3">
72
+ <label class="text-xs font-medium text-[var(--esc-panel-text-muted)]">
73
+ Start
74
+ <input
75
+ v-model="customStart"
76
+ type="date"
77
+ class="mt-1 block w-full rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] px-3 py-1.5 text-sm text-[var(--esc-panel-text)]"
78
+ />
79
+ </label>
80
+ <label class="text-xs font-medium text-[var(--esc-panel-text-muted)]">
81
+ End
82
+ <input
83
+ v-model="customEnd"
84
+ type="date"
85
+ class="mt-1 block w-full rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] px-3 py-1.5 text-sm text-[var(--esc-panel-text)]"
86
+ />
87
+ </label>
88
+ <button
89
+ class="rounded-lg bg-[var(--esc-panel-accent)] px-4 py-1.5 text-sm font-medium text-white transition-all hover:opacity-90"
90
+ @click="applyCustom"
91
+ >
92
+ Apply
93
+ </button>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </template>
@@ -0,0 +1,107 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+
4
+ const props = defineProps({
5
+ permissions: {
6
+ type: Object,
7
+ required: true,
8
+ },
9
+ modelValue: {
10
+ type: Array,
11
+ default: () => [],
12
+ },
13
+ });
14
+
15
+ const emit = defineEmits(['update:modelValue']);
16
+
17
+ const groups = computed(() => Object.keys(props.permissions || {}));
18
+
19
+ const actions = ['view', 'create', 'edit', 'delete'];
20
+
21
+ function getPermission(group, action) {
22
+ const perms = props.permissions[group] || [];
23
+ return perms.find((p) => p.slug === `${group.toLowerCase()}.${action}` || p.slug.endsWith(`.${action}`));
24
+ }
25
+
26
+ function isChecked(permissionId) {
27
+ return props.modelValue.includes(permissionId);
28
+ }
29
+
30
+ function toggle(permissionId) {
31
+ if (!permissionId) return;
32
+ const updated = isChecked(permissionId)
33
+ ? props.modelValue.filter((id) => id !== permissionId)
34
+ : [...props.modelValue, permissionId];
35
+ emit('update:modelValue', updated);
36
+ }
37
+
38
+ function toggleGroup(group) {
39
+ const groupPerms = (props.permissions[group] || []).map((p) => p.id);
40
+ const allChecked = groupPerms.every((id) => props.modelValue.includes(id));
41
+
42
+ let updated;
43
+ if (allChecked) {
44
+ updated = props.modelValue.filter((id) => !groupPerms.includes(id));
45
+ } else {
46
+ const newIds = groupPerms.filter((id) => !props.modelValue.includes(id));
47
+ updated = [...props.modelValue, ...newIds];
48
+ }
49
+ emit('update:modelValue', updated);
50
+ }
51
+
52
+ function isGroupAllChecked(group) {
53
+ const groupPerms = (props.permissions[group] || []).map((p) => p.id);
54
+ return groupPerms.length > 0 && groupPerms.every((id) => props.modelValue.includes(id));
55
+ }
56
+ </script>
57
+
58
+ <template>
59
+ <div class="overflow-hidden rounded-xl border border-white/[0.06] bg-neutral-900/60">
60
+ <table class="min-w-full divide-y divide-white/[0.06]">
61
+ <thead>
62
+ <tr class="bg-white/[0.02]">
63
+ <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">
64
+ Group
65
+ </th>
66
+ <th
67
+ v-for="action in actions"
68
+ :key="action"
69
+ class="px-4 py-3 text-center text-[11px] font-semibold uppercase tracking-wider text-neutral-500"
70
+ >
71
+ {{ action }}
72
+ </th>
73
+ <th
74
+ class="px-4 py-3 text-center text-[11px] font-semibold uppercase tracking-wider text-neutral-500"
75
+ >
76
+ All
77
+ </th>
78
+ </tr>
79
+ </thead>
80
+ <tbody class="divide-y divide-white/[0.04]">
81
+ <tr v-for="group in groups" :key="group" class="transition-colors hover:bg-white/[0.03]">
82
+ <td class="px-4 py-3 text-sm font-medium text-neutral-200">{{ group }}</td>
83
+ <td v-for="action in actions" :key="action" class="px-4 py-3 text-center">
84
+ <template v-if="getPermission(group, action)">
85
+ <input
86
+ type="checkbox"
87
+ :checked="isChecked(getPermission(group, action).id)"
88
+ :aria-label="group + ' ' + action + ' permission'"
89
+ class="rounded border-white/20 bg-neutral-900 text-cyan-500 focus:ring-white/10"
90
+ @change="toggle(getPermission(group, action).id)"
91
+ />
92
+ </template>
93
+ <span v-else class="text-neutral-600">-</span>
94
+ </td>
95
+ <td class="px-4 py-3 text-center">
96
+ <input
97
+ type="checkbox"
98
+ :checked="isGroupAllChecked(group)"
99
+ class="rounded border-white/20 bg-neutral-900 text-cyan-500 focus:ring-white/10"
100
+ @change="toggleGroup(group)"
101
+ />
102
+ </td>
103
+ </tr>
104
+ </tbody>
105
+ </table>
106
+ </div>
107
+ </template>
@@ -1,90 +1,129 @@
1
- <script setup>
2
- import { ref, computed, inject } from 'vue';
3
- import { router } from '@inertiajs/vue3';
4
- import { sanitizeHtml } from '../utils/sanitizeHtml';
5
- import { useI18n } from '../composables/useI18n';
6
-
7
- const props = defineProps({
8
- notes: { type: Array, default: () => [] },
9
- ticketReference: { type: String, required: true },
10
- routePrefix: { type: String, required: true },
11
- });
12
-
13
- const escDark = inject('esc-dark', computed(() => false));
14
- const { t } = useI18n();
15
- const processingId = ref(null);
16
-
17
- function unpinNote(note) {
18
- if (processingId.value) return;
19
- processingId.value = note.id;
20
- router.post(route(`${props.routePrefix}.tickets.pin`, [props.ticketReference, note.id]), {}, {
21
- preserveScroll: true,
22
- onFinish: () => {
23
- processingId.value = null;
24
- },
25
- });
26
- }
27
-
28
- function formatDate(dateStr) {
29
- if (!dateStr) return '';
30
- const date = new Date(dateStr);
31
- return date.toLocaleDateString(undefined, {
32
- month: 'short',
33
- day: 'numeric',
34
- year: 'numeric',
35
- hour: 'numeric',
36
- minute: '2-digit',
37
- });
38
- }
39
- </script>
40
-
41
- <template>
42
- <div v-if="notes.length > 0"
43
- :class="['rounded-xl border p-4',
44
- escDark
45
- ? 'border-amber-500/20 bg-amber-500/[0.06]'
46
- : 'border-amber-300 bg-amber-50']">
47
- <!-- Header -->
48
- <div class="mb-3 flex items-center gap-2">
49
- <svg :class="['h-4 w-4', escDark ? 'text-amber-400' : 'text-amber-600']"
50
- fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
51
- <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 3.75V16.5L12 14.25 7.5 16.5V3.75m9 0H18A2.25 2.25 0 0120.25 6v12A2.25 2.25 0 0118 20.25H6A2.25 2.25 0 013.75 18V6A2.25 2.25 0 016 3.75h1.5m9 0h-9" />
52
- </svg>
53
- <span :class="['text-xs font-semibold uppercase tracking-wider',
54
- escDark ? 'text-amber-400' : 'text-amber-700']">
55
- {{ t('pinned_notes.title') }}
56
- </span>
57
- </div>
58
-
59
- <!-- Notes list -->
60
- <div class="space-y-3">
61
- <div v-for="note in notes" :key="note.id"
62
- :class="['rounded-lg border p-3',
63
- escDark
64
- ? 'border-amber-500/10 bg-amber-500/[0.04]'
65
- : 'border-amber-200 bg-white']">
66
- <!-- Note body -->
67
- <div :class="['prose prose-sm max-w-none', escDark ? 'prose-invert text-neutral-200' : 'text-gray-800']" v-html="sanitizeHtml(note.body)"></div>
68
-
69
- <!-- Meta row -->
70
- <div class="mt-2 flex items-center justify-between">
71
- <div :class="['flex items-center gap-2 text-xs',
72
- escDark ? 'text-neutral-500' : 'text-gray-500']">
73
- <span class="font-medium">{{ note.author?.name || 'Unknown' }}</span>
74
- <span>&middot;</span>
75
- <span>{{ formatDate(note.created_at) }}</span>
76
- </div>
77
- <button @click="unpinNote(note)"
78
- :disabled="processingId === note.id"
79
- :class="['rounded-md px-2 py-1 text-xs font-medium transition-colors',
80
- escDark
81
- ? 'text-amber-400 hover:bg-amber-500/10 hover:text-amber-300'
82
- : 'text-amber-600 hover:bg-amber-100 hover:text-amber-700',
83
- processingId === note.id && 'opacity-50 cursor-not-allowed']">
84
- {{ t('pinned_notes.unpin') }}
85
- </button>
86
- </div>
87
- </div>
88
- </div>
89
- </div>
90
- </template>
1
+ <script setup>
2
+ import { ref, computed, inject } from 'vue';
3
+ import { router } from '@inertiajs/vue3';
4
+ import { sanitizeHtml } from '../utils/sanitizeHtml';
5
+ import { useI18n } from '../composables/useI18n';
6
+
7
+ const props = defineProps({
8
+ notes: { type: Array, default: () => [] },
9
+ ticketReference: { type: String, required: true },
10
+ routePrefix: { type: String, required: true },
11
+ });
12
+
13
+ const escDark = inject(
14
+ 'esc-dark',
15
+ computed(() => false),
16
+ );
17
+ const { t } = useI18n();
18
+ const processingId = ref(null);
19
+
20
+ function unpinNote(note) {
21
+ if (processingId.value) return;
22
+ processingId.value = note.id;
23
+ router.post(
24
+ route(`${props.routePrefix}.tickets.pin`, [props.ticketReference, note.id]),
25
+ {},
26
+ {
27
+ preserveScroll: true,
28
+ onFinish: () => {
29
+ processingId.value = null;
30
+ },
31
+ },
32
+ );
33
+ }
34
+
35
+ function formatDate(dateStr) {
36
+ if (!dateStr) return '';
37
+ const date = new Date(dateStr);
38
+ return date.toLocaleDateString(undefined, {
39
+ month: 'short',
40
+ day: 'numeric',
41
+ year: 'numeric',
42
+ hour: 'numeric',
43
+ minute: '2-digit',
44
+ });
45
+ }
46
+ </script>
47
+
48
+ <template>
49
+ <div
50
+ v-if="notes.length > 0"
51
+ :class="[
52
+ 'rounded-xl border p-4',
53
+ escDark ? 'border-amber-500/20 bg-amber-500/[0.06]' : 'border-amber-300 bg-amber-50',
54
+ ]"
55
+ >
56
+ <!-- Header -->
57
+ <div class="mb-3 flex items-center gap-2">
58
+ <svg
59
+ :class="['h-4 w-4', escDark ? 'text-amber-400' : 'text-amber-600']"
60
+ fill="none"
61
+ stroke="currentColor"
62
+ stroke-width="1.5"
63
+ viewBox="0 0 24 24"
64
+ >
65
+ <path
66
+ stroke-linecap="round"
67
+ stroke-linejoin="round"
68
+ d="M16.5 3.75V16.5L12 14.25 7.5 16.5V3.75m9 0H18A2.25 2.25 0 0120.25 6v12A2.25 2.25 0 0118 20.25H6A2.25 2.25 0 013.75 18V6A2.25 2.25 0 016 3.75h1.5m9 0h-9"
69
+ />
70
+ </svg>
71
+ <span
72
+ :class="[
73
+ 'text-xs font-semibold uppercase tracking-wider',
74
+ escDark ? 'text-amber-400' : 'text-amber-700',
75
+ ]"
76
+ >
77
+ {{ t('pinned_notes.title') }}
78
+ </span>
79
+ </div>
80
+
81
+ <!-- Notes list -->
82
+ <div class="space-y-3">
83
+ <div
84
+ v-for="note in notes"
85
+ :key="note.id"
86
+ :class="[
87
+ 'rounded-lg border p-3',
88
+ escDark ? 'border-amber-500/10 bg-amber-500/[0.04]' : 'border-amber-200 bg-white',
89
+ ]"
90
+ >
91
+ <!-- Note body -->
92
+ <div
93
+ :class="[
94
+ 'prose prose-sm max-w-none',
95
+ escDark ? 'prose-invert text-[var(--esc-panel-text-secondary)]' : 'text-gray-800',
96
+ ]"
97
+ v-html="sanitizeHtml(note.body)"
98
+ ></div>
99
+
100
+ <!-- Meta row -->
101
+ <div class="mt-2 flex items-center justify-between">
102
+ <div
103
+ :class="[
104
+ 'flex items-center gap-2 text-xs',
105
+ escDark ? 'text-[var(--esc-panel-text-muted)]' : 'text-gray-500',
106
+ ]"
107
+ >
108
+ <span class="font-medium">{{ note.author?.name || 'Unknown' }}</span>
109
+ <span>&middot;</span>
110
+ <span>{{ formatDate(note.created_at) }}</span>
111
+ </div>
112
+ <button
113
+ :disabled="processingId === note.id"
114
+ :class="[
115
+ 'rounded-md px-2 py-1 text-xs font-medium transition-colors',
116
+ escDark
117
+ ? 'text-amber-400 hover:bg-amber-500/10 hover:text-amber-300'
118
+ : 'text-amber-600 hover:bg-amber-100 hover:text-amber-700',
119
+ processingId === note.id && 'opacity-50 cursor-not-allowed',
120
+ ]"
121
+ @click="unpinNote(note)"
122
+ >
123
+ {{ t('pinned_notes.unpin') }}
124
+ </button>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ </template>