@escalated-dev/escalated 0.2.1 → 0.3.5

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 (33) hide show
  1. package/package.json +1 -1
  2. package/src/components/ActivityTimeline.vue +8 -4
  3. package/src/components/AssigneeSelect.vue +6 -2
  4. package/src/components/AttachmentList.vue +9 -3
  5. package/src/components/EscalatedLayout.vue +219 -213
  6. package/src/components/FileDropzone.vue +9 -4
  7. package/src/components/PriorityBadge.vue +20 -2
  8. package/src/components/ReplyComposer.vue +52 -3
  9. package/src/components/ReplyThread.vue +14 -6
  10. package/src/components/SlaTimer.vue +12 -6
  11. package/src/components/StatsCard.vue +26 -6
  12. package/src/components/StatusBadge.vue +24 -2
  13. package/src/components/TagSelect.vue +8 -4
  14. package/src/components/TicketFilters.vue +17 -11
  15. package/src/components/TicketList.vue +45 -2
  16. package/src/components/TicketSidebar.vue +21 -14
  17. package/src/pages/Admin/CannedResponses/Index.vue +29 -17
  18. package/src/pages/Admin/Departments/Form.vue +12 -11
  19. package/src/pages/Admin/Departments/Index.vue +26 -18
  20. package/src/pages/Admin/EscalationRules/Form.vue +21 -20
  21. package/src/pages/Admin/EscalationRules/Index.vue +26 -18
  22. package/src/pages/Admin/Reports.vue +30 -16
  23. package/src/pages/Admin/Settings.vue +260 -0
  24. package/src/pages/Admin/SlaPolicies/Form.vue +21 -20
  25. package/src/pages/Admin/SlaPolicies/Index.vue +28 -20
  26. package/src/pages/Admin/Tags/Index.vue +48 -23
  27. package/src/pages/Admin/Tickets/Index.vue +22 -0
  28. package/src/pages/Admin/Tickets/Show.vue +109 -0
  29. package/src/pages/Agent/Dashboard.vue +37 -25
  30. package/src/pages/Agent/TicketShow.vue +12 -12
  31. package/src/pages/Customer/Show.vue +2 -2
  32. package/src/pages/Guest/Create.vue +97 -0
  33. package/src/pages/Guest/Show.vue +86 -0
@@ -1,9 +1,21 @@
1
1
  <script setup>
2
+ import { inject, computed } from 'vue';
3
+
2
4
  const props = defineProps({
3
5
  priority: { type: String, required: true },
4
6
  });
5
7
 
6
- const priorityConfig = {
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 = {
7
19
  low: { label: 'Low', color: 'bg-gray-100 text-gray-800' },
8
20
  medium: { label: 'Medium', color: 'bg-blue-100 text-blue-800' },
9
21
  high: { label: 'High', color: 'bg-yellow-100 text-yellow-800' },
@@ -11,7 +23,13 @@ const priorityConfig = {
11
23
  critical: { label: 'Critical', color: 'bg-red-100 text-red-800' },
12
24
  };
13
25
 
14
- const config = priorityConfig[props.priority] || { label: props.priority, color: 'bg-gray-100 text-gray-800' };
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
+ });
15
33
  </script>
16
34
 
17
35
  <template>
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed } from 'vue';
2
+ import { ref, computed, inject } from 'vue';
3
3
  import { router } from '@inertiajs/vue3';
4
4
  import FileDropzone from './FileDropzone.vue';
5
5
 
@@ -12,6 +12,7 @@ const props = defineProps({
12
12
  });
13
13
 
14
14
  const emit = defineEmits(['submit']);
15
+ const escDark = inject('esc-dark', computed(() => false));
15
16
 
16
17
  const body = ref('');
17
18
  const isNote = ref(false);
@@ -62,7 +63,55 @@ const buttonLabel = computed(() => {
62
63
  </script>
63
64
 
64
65
  <template>
65
- <div class="rounded-lg border border-gray-200 bg-white p-4">
66
+ <!-- Dark mode -->
67
+ <div v-if="escDark" class="rounded-xl border border-white/[0.06] bg-neutral-900/60 p-4">
68
+ <div v-if="allowNotes" class="mb-3 flex gap-2">
69
+ <button @click="isNote = false"
70
+ :class="['rounded-lg px-3 py-1.5 text-sm font-medium transition-colors', !isNote ? 'bg-cyan-500/15 text-white ring-1 ring-cyan-500/20' : 'text-gray-400 hover:bg-white/[0.04]']">
71
+ Reply
72
+ </button>
73
+ <button @click="isNote = true"
74
+ :class="['rounded-lg px-3 py-1.5 text-sm font-medium transition-colors', isNote ? 'bg-amber-500/15 text-amber-400 ring-1 ring-amber-500/20' : 'text-gray-400 hover:bg-white/[0.04]']">
75
+ Internal Note
76
+ </button>
77
+ </div>
78
+
79
+ <div v-if="isNote" class="mb-2 rounded-lg bg-amber-500/10 px-3 py-1.5 text-xs text-amber-400 ring-1 ring-amber-500/20">
80
+ This note is only visible to agents.
81
+ </div>
82
+
83
+ <textarea v-model="body" rows="4" :placeholder="placeholder"
84
+ class="w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"></textarea>
85
+
86
+ <FileDropzone @files="handleFiles" class="mt-2" />
87
+
88
+ <div v-if="files.length" class="mt-2 space-y-1">
89
+ <div v-for="(file, i) in files" :key="i" class="flex items-center gap-2 text-sm text-gray-400">
90
+ <span>{{ file.name }}</span>
91
+ <button @click="removeFile(i)" class="text-rose-400 hover:text-rose-300">&times;</button>
92
+ </div>
93
+ </div>
94
+
95
+ <div class="mt-3 flex items-center justify-between">
96
+ <div v-if="cannedResponses.length">
97
+ <select @change="insertCanned(cannedResponses[$event.target.value]); $event.target.value = ''"
98
+ class="rounded-lg border border-white/10 bg-gray-900 px-2 py-1 text-xs text-gray-400">
99
+ <option value="">Canned responses...</option>
100
+ <option v-for="(cr, i) in cannedResponses" :key="cr.id" :value="i">{{ cr.title }}</option>
101
+ </select>
102
+ </div>
103
+ <div v-else></div>
104
+ <button @click="submit" :disabled="!body.trim() || submitting"
105
+ :class="['rounded-lg px-4 py-2 text-sm font-medium text-white transition-all',
106
+ isNote ? 'bg-amber-500 hover:bg-amber-400' : 'bg-gradient-to-r from-cyan-500 to-violet-500 hover:from-cyan-400 hover:to-violet-400',
107
+ (!body.trim() || submitting) && 'cursor-not-allowed opacity-40']">
108
+ {{ buttonLabel }}
109
+ </button>
110
+ </div>
111
+ </div>
112
+
113
+ <!-- Light mode -->
114
+ <div v-else class="rounded-lg border border-gray-200 bg-white p-4">
66
115
  <div v-if="allowNotes" class="mb-3 flex gap-2">
67
116
  <button @click="isNote = false"
68
117
  :class="['rounded-md px-3 py-1 text-sm font-medium', !isNote ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:bg-gray-100']">
@@ -91,7 +140,7 @@ const buttonLabel = computed(() => {
91
140
  </div>
92
141
 
93
142
  <div class="mt-3 flex items-center justify-between">
94
- <div v-if="cannedResponses.length" class="relative">
143
+ <div v-if="cannedResponses.length">
95
144
  <select @change="insertCanned(cannedResponses[$event.target.value]); $event.target.value = ''"
96
145
  class="rounded border border-gray-300 px-2 py-1 text-xs text-gray-600">
97
146
  <option value="">Canned responses...</option>
@@ -1,4 +1,5 @@
1
1
  <script setup>
2
+ import { inject, computed } from 'vue';
2
3
  import AttachmentList from './AttachmentList.vue';
3
4
 
4
5
  defineProps({
@@ -6,6 +7,8 @@ defineProps({
6
7
  currentUserId: { type: [Number, String], default: null },
7
8
  });
8
9
 
10
+ const escDark = inject('esc-dark', computed(() => false));
11
+
9
12
  function formatDate(date) {
10
13
  return new Date(date).toLocaleString();
11
14
  }
@@ -14,17 +17,22 @@ function formatDate(date) {
14
17
  <template>
15
18
  <div class="space-y-4">
16
19
  <div v-for="reply in replies" :key="reply.id"
17
- :class="['rounded-lg border p-4', reply.is_internal_note ? 'border-yellow-200 bg-yellow-50' : 'border-gray-200 bg-white']">
20
+ :class="['rounded-xl border p-4', escDark
21
+ ? (reply.is_internal_note ? 'border-amber-500/20 bg-amber-500/5' : 'border-white/[0.06] bg-neutral-900/60')
22
+ : (reply.is_internal_note ? 'border-yellow-200 bg-yellow-50' : 'border-gray-200 bg-white')]">
18
23
  <div class="mb-2 flex items-center justify-between">
19
24
  <div class="flex items-center gap-2">
20
- <span class="font-medium text-gray-900">{{ reply.author?.name || 'Unknown' }}</span>
21
- <span v-if="reply.is_internal_note" class="rounded bg-yellow-200 px-1.5 py-0.5 text-xs font-medium text-yellow-800">Internal Note</span>
25
+ <span :class="['font-medium', escDark ? 'text-gray-200' : 'text-gray-900']">{{ reply.author?.name || 'Unknown' }}</span>
26
+ <span v-if="reply.is_internal_note"
27
+ :class="['rounded px-1.5 py-0.5 text-xs font-medium', escDark ? 'bg-amber-500/15 text-amber-400' : 'bg-yellow-200 text-yellow-800']">
28
+ Internal Note
29
+ </span>
22
30
  </div>
23
- <span class="text-xs text-gray-500">{{ formatDate(reply.created_at) }}</span>
31
+ <span :class="['text-xs', escDark ? 'text-gray-500' : 'text-gray-500']">{{ formatDate(reply.created_at) }}</span>
24
32
  </div>
25
- <div class="prose prose-sm max-w-none text-gray-700" v-html="reply.body"></div>
33
+ <div :class="['prose prose-sm max-w-none', escDark ? 'prose-invert text-gray-300' : 'text-gray-700']" v-html="reply.body"></div>
26
34
  <AttachmentList v-if="reply.attachments?.length" :attachments="reply.attachments" class="mt-3" />
27
35
  </div>
28
- <div v-if="!replies?.length" class="py-8 text-center text-sm text-gray-500">No replies yet.</div>
36
+ <div v-if="!replies?.length" :class="['py-8 text-center text-sm', escDark ? 'text-gray-500' : 'text-gray-500']">No replies yet.</div>
29
37
  </div>
30
38
  </template>
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed } from 'vue';
2
+ import { computed, inject } from 'vue';
3
3
 
4
4
  const props = defineProps({
5
5
  dueAt: { type: String, default: null },
@@ -7,6 +7,8 @@ const props = defineProps({
7
7
  label: { type: String, default: 'Due' },
8
8
  });
9
9
 
10
+ const dark = inject('esc-dark', computed(() => false));
11
+
10
12
  const timeRemaining = computed(() => {
11
13
  if (!props.dueAt) return null;
12
14
  const now = new Date();
@@ -24,19 +26,23 @@ const timeRemaining = computed(() => {
24
26
  });
25
27
 
26
28
  const statusClass = computed(() => {
27
- if (props.breached || timeRemaining.value?.overdue) return 'border-red-300 bg-red-50 text-red-700';
29
+ if (props.breached || timeRemaining.value?.overdue) {
30
+ return dark ? 'border-rose-500/20 bg-rose-500/10 text-rose-400' : 'border-red-300 bg-red-50 text-red-700';
31
+ }
28
32
  const due = new Date(props.dueAt);
29
33
  const hoursLeft = (due - new Date()) / 3600000;
30
- if (hoursLeft < 2) return 'border-yellow-300 bg-yellow-50 text-yellow-700';
31
- return 'border-green-300 bg-green-50 text-green-700';
34
+ if (hoursLeft < 2) {
35
+ return dark ? 'border-amber-500/20 bg-amber-500/10 text-amber-400' : 'border-yellow-300 bg-yellow-50 text-yellow-700';
36
+ }
37
+ return dark ? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400' : 'border-green-300 bg-green-50 text-green-700';
32
38
  });
33
39
  </script>
34
40
 
35
41
  <template>
36
- <div v-if="dueAt" :class="['rounded-md border px-3 py-2 text-sm', statusClass]">
42
+ <div v-if="dueAt" :class="['rounded-lg border px-3 py-2 text-sm', statusClass]">
37
43
  <div class="font-medium">{{ label }}</div>
38
44
  <div class="text-xs">
39
- <span v-if="breached">⚠ SLA Breached</span>
45
+ <span v-if="breached">SLA Breached</span>
40
46
  <span v-else>{{ timeRemaining?.text }}</span>
41
47
  </div>
42
48
  </div>
@@ -1,25 +1,45 @@
1
1
  <script setup>
2
- defineProps({
3
- label: { type: String, required: true },
2
+ import { inject, computed } from 'vue';
3
+
4
+ const props = defineProps({
5
+ label: { type: String, default: '' },
6
+ title: { type: String, default: '' },
4
7
  value: { type: [Number, String], required: true },
5
8
  trend: { type: String, default: null },
6
9
  color: { type: String, default: 'blue' },
7
10
  });
8
11
 
9
- const colorMap = {
12
+ const escDark = inject('esc-dark', computed(() => false));
13
+ const displayLabel = computed(() => props.label || props.title || '');
14
+
15
+ const lightColorMap = {
10
16
  blue: 'bg-blue-50 text-blue-700',
17
+ indigo: 'bg-indigo-50 text-indigo-700',
11
18
  green: 'bg-green-50 text-green-700',
12
19
  red: 'bg-red-50 text-red-700',
13
20
  yellow: 'bg-yellow-50 text-yellow-700',
14
21
  gray: 'bg-gray-50 text-gray-700',
22
+ cyan: 'bg-cyan-50 text-cyan-700',
23
+ violet: 'bg-violet-50 text-violet-700',
24
+ amber: 'bg-amber-50 text-amber-700',
15
25
  };
16
26
  </script>
17
27
 
18
28
  <template>
19
- <div class="rounded-lg border border-gray-200 bg-white p-4">
20
- <div class="text-sm text-gray-500">{{ label }}</div>
29
+ <!-- Dark mode -->
30
+ <div v-if="escDark" class="rounded-xl border border-white/[0.06] bg-neutral-900/60 p-5">
31
+ <div class="text-[13px] font-medium text-neutral-500">{{ displayLabel }}</div>
32
+ <div class="mt-2 text-2xl font-bold tracking-tight text-white">{{ value }}</div>
33
+ <div v-if="trend" class="mt-2 inline-flex items-center rounded-full bg-white/[0.06] px-2 py-0.5 text-xs font-medium text-neutral-400">
34
+ {{ trend }}
35
+ </div>
36
+ </div>
37
+
38
+ <!-- Light mode -->
39
+ <div v-else class="rounded-lg border border-gray-200 bg-white p-4">
40
+ <div class="text-sm text-gray-500">{{ displayLabel }}</div>
21
41
  <div class="mt-1 text-2xl font-bold text-gray-900">{{ value }}</div>
22
- <div v-if="trend" :class="['mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-medium', colorMap[color] || colorMap.blue]">
42
+ <div v-if="trend" :class="['mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-medium', lightColorMap[color] || lightColorMap.blue]">
23
43
  {{ trend }}
24
44
  </div>
25
45
  </div>
@@ -1,9 +1,24 @@
1
1
  <script setup>
2
+ import { inject, computed } from 'vue';
3
+
2
4
  const props = defineProps({
3
5
  status: { type: String, required: true },
4
6
  });
5
7
 
6
- const statusConfig = {
8
+ const escDark = inject('esc-dark', computed(() => false));
9
+
10
+ const darkConfig = {
11
+ open: { label: 'Open', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
12
+ in_progress: { label: 'In Progress', color: 'bg-violet-500/10 text-violet-400 ring-1 ring-violet-500/20' },
13
+ waiting_on_customer: { label: 'Waiting on Customer', color: 'bg-amber-500/10 text-amber-400 ring-1 ring-amber-500/20' },
14
+ waiting_on_agent: { label: 'Waiting on Agent', color: 'bg-orange-500/10 text-orange-400 ring-1 ring-orange-500/20' },
15
+ escalated: { label: 'Escalated', color: 'bg-rose-500/10 text-rose-400 ring-1 ring-rose-500/20' },
16
+ resolved: { label: 'Resolved', color: 'bg-emerald-500/10 text-emerald-400 ring-1 ring-emerald-500/20' },
17
+ closed: { label: 'Closed', color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' },
18
+ reopened: { label: 'Reopened', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
19
+ };
20
+
21
+ const lightConfig = {
7
22
  open: { label: 'Open', color: 'bg-blue-100 text-blue-800' },
8
23
  in_progress: { label: 'In Progress', color: 'bg-purple-100 text-purple-800' },
9
24
  waiting_on_customer: { label: 'Waiting on Customer', color: 'bg-yellow-100 text-yellow-800' },
@@ -14,7 +29,14 @@ const statusConfig = {
14
29
  reopened: { label: 'Reopened', color: 'bg-blue-100 text-blue-800' },
15
30
  };
16
31
 
17
- const config = statusConfig[props.status] || { label: props.status, color: 'bg-gray-100 text-gray-800' };
32
+ const fallback = { label: props.status, color: 'bg-gray-100 text-gray-800' };
33
+ const darkFallback = { label: props.status, color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' };
34
+
35
+ const config = computed(() => {
36
+ const map = escDark.value ? darkConfig : lightConfig;
37
+ const fb = escDark.value ? darkFallback : fallback;
38
+ return map[props.status] || fb;
39
+ });
18
40
  </script>
19
41
 
20
42
  <template>
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed } from 'vue';
2
+ import { ref, computed, inject } from 'vue';
3
3
 
4
4
  const props = defineProps({
5
5
  tags: { type: Array, required: true },
@@ -7,6 +7,7 @@ const props = defineProps({
7
7
  });
8
8
 
9
9
  const emit = defineEmits(['update:modelValue']);
10
+ const dark = inject('esc-dark', computed(() => false));
10
11
 
11
12
  const search = ref('');
12
13
 
@@ -34,13 +35,16 @@ function isSelected(tagId) {
34
35
 
35
36
  <template>
36
37
  <div>
37
- <label class="mb-1 block text-xs font-medium text-gray-600">Tags</label>
38
+ <label :class="['mb-1 block text-xs font-medium', dark ? 'text-neutral-500' : 'text-gray-600']">Tags</label>
38
39
  <input v-model="search" type="text" placeholder="Filter tags..."
39
- class="mb-2 w-full rounded-md border border-gray-300 px-2 py-1 text-xs focus:border-blue-500 focus:outline-none" />
40
+ :class="['mb-2 w-full rounded-md border px-2 py-1 text-xs focus:outline-none',
41
+ dark ? 'border-white/10 bg-neutral-950 text-neutral-200 placeholder-neutral-600 focus:border-white/20 focus:ring-1 focus:ring-white/10' : 'border-gray-300 focus:border-blue-500']" />
40
42
  <div class="flex flex-wrap gap-1">
41
43
  <button v-for="tag in filteredTags" :key="tag.id" @click="toggle(tag.id)"
42
44
  :class="['rounded-full px-2 py-0.5 text-xs font-medium transition-colors',
43
- isSelected(tag.id) ? 'bg-blue-100 text-blue-700 ring-1 ring-blue-300' : 'bg-gray-100 text-gray-600 hover:bg-gray-200']"
45
+ dark
46
+ ? (isSelected(tag.id) ? 'bg-white/[0.12] text-white ring-1 ring-white/20' : 'bg-white/[0.04] text-neutral-500 hover:bg-white/[0.08]')
47
+ : (isSelected(tag.id) ? 'bg-blue-100 text-blue-700 ring-1 ring-blue-300' : 'bg-gray-100 text-gray-600 hover:bg-gray-200')]"
44
48
  :style="tag.color ? { backgroundColor: isSelected(tag.id) ? tag.color + '33' : undefined, color: isSelected(tag.id) ? tag.color : undefined } : {}">
45
49
  {{ tag.name }}
46
50
  </button>
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { reactive, watch } from 'vue';
2
+ import { inject, computed, reactive, watch } from 'vue';
3
3
 
4
4
  const props = defineProps({
5
5
  statuses: { type: Array, default: () => ['open', 'in_progress', 'waiting_on_customer', 'waiting_on_agent', 'escalated', 'resolved', 'closed'] },
@@ -10,6 +10,7 @@ const props = defineProps({
10
10
  });
11
11
 
12
12
  const emit = defineEmits(['update:modelValue']);
13
+ const escDark = inject('esc-dark', computed(() => false));
13
14
 
14
15
  const filters = reactive({
15
16
  status: props.modelValue.status || '',
@@ -22,29 +23,34 @@ const filters = reactive({
22
23
  watch(filters, (val) => {
23
24
  emit('update:modelValue', { ...val });
24
25
  }, { deep: true });
26
+
27
+ const inputClass = computed(() => escDark.value
28
+ ? 'rounded-lg border border-white/10 bg-gray-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10'
29
+ : 'rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none'
30
+ );
31
+
32
+ const selectClass = computed(() => escDark.value
33
+ ? 'rounded-lg border border-white/10 bg-gray-900 px-2 py-1.5 text-sm text-gray-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10'
34
+ : 'rounded-md border border-gray-300 px-2 py-1.5 text-sm'
35
+ );
25
36
  </script>
26
37
 
27
38
  <template>
28
39
  <div class="flex flex-wrap items-center gap-3">
29
- <input v-model="filters.search" type="text" placeholder="Search tickets..."
30
- class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none" />
31
-
32
- <select v-model="filters.status" class="rounded-md border border-gray-300 px-2 py-1.5 text-sm">
40
+ <input v-model="filters.search" type="text" placeholder="Search tickets..." :class="inputClass" />
41
+ <select v-model="filters.status" :class="selectClass">
33
42
  <option value="">All Statuses</option>
34
43
  <option v-for="s in statuses" :key="s" :value="s">{{ s.replace(/_/g, ' ') }}</option>
35
44
  </select>
36
-
37
- <select v-model="filters.priority" class="rounded-md border border-gray-300 px-2 py-1.5 text-sm">
45
+ <select v-model="filters.priority" :class="selectClass">
38
46
  <option value="">All Priorities</option>
39
47
  <option v-for="p in priorities" :key="p" :value="p">{{ p }}</option>
40
48
  </select>
41
-
42
- <select v-if="agents.length" v-model="filters.assigned_to" class="rounded-md border border-gray-300 px-2 py-1.5 text-sm">
49
+ <select v-if="agents.length" v-model="filters.assigned_to" :class="selectClass">
43
50
  <option value="">All Agents</option>
44
51
  <option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
45
52
  </select>
46
-
47
- <select v-if="departments.length" v-model="filters.department_id" class="rounded-md border border-gray-300 px-2 py-1.5 text-sm">
53
+ <select v-if="departments.length" v-model="filters.department_id" :class="selectClass">
48
54
  <option value="">All Departments</option>
49
55
  <option v-for="d in departments" :key="d.id" :value="d.id">{{ d.name }}</option>
50
56
  </select>
@@ -1,4 +1,5 @@
1
1
  <script setup>
2
+ import { inject, computed } from 'vue';
2
3
  import StatusBadge from './StatusBadge.vue';
3
4
  import PriorityBadge from './PriorityBadge.vue';
4
5
  import { Link } from '@inertiajs/vue3';
@@ -8,10 +9,52 @@ defineProps({
8
9
  routePrefix: { type: String, default: 'escalated.customer.tickets' },
9
10
  showAssignee: { type: Boolean, default: false },
10
11
  });
12
+
13
+ const escDark = inject('esc-dark', computed(() => false));
11
14
  </script>
12
15
 
13
16
  <template>
14
- <div class="overflow-hidden rounded-lg border border-gray-200 bg-white">
17
+ <!-- Dark mode -->
18
+ <div v-if="escDark" class="overflow-hidden rounded-xl border border-white/[0.06] bg-neutral-900/60">
19
+ <table class="min-w-full divide-y divide-white/[0.06]">
20
+ <thead>
21
+ <tr class="bg-white/[0.02]">
22
+ <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Reference</th>
23
+ <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Subject</th>
24
+ <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Status</th>
25
+ <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Priority</th>
26
+ <th v-if="showAssignee" class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Assignee</th>
27
+ <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Created</th>
28
+ </tr>
29
+ </thead>
30
+ <tbody class="divide-y divide-white/[0.04]">
31
+ <tr v-for="ticket in tickets.data" :key="ticket.id" class="transition-colors hover:bg-white/[0.03]">
32
+ <td class="whitespace-nowrap px-4 py-3 text-sm font-medium">
33
+ <Link :href="route(`${routePrefix}.show`, ticket.reference)" class="text-white hover:text-neutral-300">
34
+ {{ ticket.reference }}
35
+ </Link>
36
+ </td>
37
+ <td class="px-4 py-3 text-sm text-neutral-300">{{ ticket.subject }}</td>
38
+ <td class="px-4 py-3"><StatusBadge :status="ticket.status" /></td>
39
+ <td class="px-4 py-3"><PriorityBadge :priority="ticket.priority" /></td>
40
+ <td v-if="showAssignee" class="px-4 py-3 text-sm text-neutral-500">
41
+ {{ ticket.assignee?.name || 'Unassigned' }}
42
+ </td>
43
+ <td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-600">
44
+ {{ new Date(ticket.created_at).toLocaleDateString() }}
45
+ </td>
46
+ </tr>
47
+ <tr v-if="!tickets.data?.length">
48
+ <td :colspan="showAssignee ? 6 : 5" class="px-4 py-8 text-center text-sm text-neutral-600">
49
+ No tickets found.
50
+ </td>
51
+ </tr>
52
+ </tbody>
53
+ </table>
54
+ </div>
55
+
56
+ <!-- Light mode -->
57
+ <div v-else class="overflow-hidden rounded-lg border border-gray-200 bg-white">
15
58
  <table class="min-w-full divide-y divide-gray-200">
16
59
  <thead class="bg-gray-50">
17
60
  <tr>
@@ -26,7 +69,7 @@ defineProps({
26
69
  <tbody class="divide-y divide-gray-200">
27
70
  <tr v-for="ticket in tickets.data" :key="ticket.id" class="hover:bg-gray-50">
28
71
  <td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900">
29
- <Link :href="route(`.show`, ticket.reference)" class="text-indigo-600 hover:text-indigo-900">
72
+ <Link :href="route(`${routePrefix}.show`, ticket.reference)" class="text-indigo-600 hover:text-indigo-900">
30
73
  {{ ticket.reference }}
31
74
  </Link>
32
75
  </td>
@@ -1,4 +1,5 @@
1
1
  <script setup>
2
+ import { inject, computed } from 'vue';
2
3
  import StatusBadge from './StatusBadge.vue';
3
4
  import PriorityBadge from './PriorityBadge.vue';
4
5
  import SlaTimer from './SlaTimer.vue';
@@ -15,32 +16,38 @@ defineProps({
15
16
  });
16
17
 
17
18
  const emit = defineEmits(['assign', 'tags', 'priority', 'department', 'status']);
19
+ const escDark = inject('esc-dark', computed(() => false));
20
+
21
+ const cardClass = computed(() => escDark.value
22
+ ? 'rounded-xl border border-white/[0.06] bg-neutral-900/60 p-4'
23
+ : 'rounded-lg border border-gray-200 bg-white p-4'
24
+ );
18
25
  </script>
19
26
 
20
27
  <template>
21
28
  <aside class="space-y-4">
22
- <div class="rounded-lg border border-gray-200 bg-white p-4">
23
- <h3 class="mb-3 text-sm font-semibold text-gray-900">Details</h3>
29
+ <div :class="cardClass">
30
+ <h3 :class="['mb-3 text-sm font-semibold', escDark ? 'text-white' : 'text-gray-900']">Details</h3>
24
31
  <dl class="space-y-2 text-sm">
25
32
  <div class="flex justify-between">
26
- <dt class="text-gray-500">Status</dt>
33
+ <dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Status</dt>
27
34
  <dd><StatusBadge :status="ticket.status" /></dd>
28
35
  </div>
29
36
  <div class="flex justify-between">
30
- <dt class="text-gray-500">Priority</dt>
37
+ <dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Priority</dt>
31
38
  <dd><PriorityBadge :priority="ticket.priority" /></dd>
32
39
  </div>
33
40
  <div class="flex justify-between">
34
- <dt class="text-gray-500">Reference</dt>
35
- <dd class="font-mono text-xs">{{ ticket.reference }}</dd>
41
+ <dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Reference</dt>
42
+ <dd :class="['font-mono text-xs', escDark ? 'text-white' : '']">{{ ticket.reference }}</dd>
36
43
  </div>
37
44
  <div v-if="ticket.department" class="flex justify-between">
38
- <dt class="text-gray-500">Department</dt>
39
- <dd>{{ ticket.department.name }}</dd>
45
+ <dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Department</dt>
46
+ <dd :class="escDark ? 'text-neutral-300' : ''">{{ ticket.department.name }}</dd>
40
47
  </div>
41
48
  <div class="flex justify-between">
42
- <dt class="text-gray-500">Created</dt>
43
- <dd>{{ new Date(ticket.created_at).toLocaleDateString() }}</dd>
49
+ <dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Created</dt>
50
+ <dd :class="escDark ? 'text-neutral-300' : ''">{{ new Date(ticket.created_at).toLocaleDateString() }}</dd>
44
51
  </div>
45
52
  </dl>
46
53
  </div>
@@ -52,18 +59,18 @@ const emit = defineEmits(['assign', 'tags', 'priority', 'department', 'status'])
52
59
  :breached="ticket.sla_resolution_breached" label="Resolution" />
53
60
  </div>
54
61
 
55
- <div v-if="editable && agents.length" class="rounded-lg border border-gray-200 bg-white p-4">
62
+ <div v-if="editable && agents.length" :class="cardClass">
56
63
  <AssigneeSelect :agents="agents" :model-value="ticket.assigned_to"
57
64
  @update:model-value="emit('assign', $event)" />
58
65
  </div>
59
66
 
60
- <div v-if="editable && tags.length" class="rounded-lg border border-gray-200 bg-white p-4">
67
+ <div v-if="editable && tags.length" :class="cardClass">
61
68
  <TagSelect :tags="tags" :model-value="(ticket.tags || []).map(t => t.id)"
62
69
  @update:model-value="emit('tags', $event)" />
63
70
  </div>
64
71
 
65
- <div v-if="activities.length" class="rounded-lg border border-gray-200 bg-white p-4">
66
- <h3 class="mb-3 text-sm font-semibold text-gray-900">Activity</h3>
72
+ <div v-if="activities.length" :class="cardClass">
73
+ <h3 :class="['mb-3 text-sm font-semibold', escDark ? 'text-white' : 'text-gray-900']">Activity</h3>
67
74
  <ActivityTimeline :activities="activities" />
68
75
  </div>
69
76
  </aside>
@@ -24,34 +24,46 @@ function destroy(id) {
24
24
  <template>
25
25
  <EscalatedLayout title="Canned Responses">
26
26
  <div class="mb-4 flex justify-end">
27
- <button @click="showForm = !showForm" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">
27
+ <button @click="showForm = !showForm"
28
+ :class="showForm
29
+ ? 'rounded-lg border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-medium text-neutral-300 transition-colors hover:bg-white/[0.06]'
30
+ : 'rounded-lg bg-gradient-to-r from-cyan-500 to-violet-500 px-4 py-2 text-sm font-medium text-white shadow-lg shadow-black/20 transition-all hover:from-cyan-400 hover:to-violet-400'">
28
31
  {{ showForm ? 'Cancel' : 'Add Response' }}
29
32
  </button>
30
33
  </div>
31
- <form v-if="showForm" @submit.prevent="create" class="mb-6 space-y-3 rounded-lg border border-gray-200 bg-white p-4">
32
- <input v-model="form.title" type="text" placeholder="Title" required class="w-full rounded-lg border-gray-300 shadow-sm" />
33
- <textarea v-model="form.body" rows="4" placeholder="Response body..." required class="w-full rounded-lg border-gray-300 shadow-sm"></textarea>
34
- <div class="flex gap-3">
35
- <input v-model="form.category" type="text" placeholder="Category (optional)" class="rounded-lg border-gray-300 shadow-sm" />
34
+ <form v-if="showForm" @submit.prevent="create" class="mb-6 space-y-3 rounded-xl border border-white/[0.06] bg-neutral-900/60 p-5">
35
+ <input v-model="form.title" type="text" placeholder="Title" required
36
+ class="w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10" />
37
+ <textarea v-model="form.body" rows="4" placeholder="Response body..." required
38
+ class="w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"></textarea>
39
+ <div class="flex items-center gap-3">
40
+ <input v-model="form.category" type="text" placeholder="Category (optional)"
41
+ class="rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10" />
36
42
  <label class="flex items-center gap-2">
37
- <input v-model="form.is_shared" type="checkbox" class="rounded border-gray-300" />
38
- <span class="text-sm text-gray-700">Shared</span>
43
+ <input v-model="form.is_shared" type="checkbox" class="rounded border-white/20 bg-neutral-900 text-white focus:ring-white/10" />
44
+ <span class="text-sm text-neutral-300">Shared</span>
39
45
  </label>
40
- <button type="submit" :disabled="form.processing" class="ml-auto rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">Create</button>
46
+ <button type="submit" :disabled="form.processing"
47
+ class="ml-auto rounded-lg bg-gradient-to-r from-cyan-500 to-violet-500 px-4 py-2 text-sm font-medium text-white shadow-lg shadow-black/20 transition-all hover:from-cyan-400 hover:to-violet-400 disabled:opacity-50">
48
+ Create
49
+ </button>
41
50
  </div>
42
51
  </form>
43
52
  <div class="space-y-3">
44
- <div v-for="resp in responses" :key="resp.id" class="rounded-lg border border-gray-200 bg-white p-4">
53
+ <div v-if="!responses?.length" class="rounded-xl border border-white/[0.06] bg-neutral-900/60 px-4 py-12 text-center">
54
+ <svg class="mx-auto mb-3 h-8 w-8 text-neutral-700" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></svg>
55
+ <p class="text-sm text-neutral-500">No canned responses yet</p>
56
+ <p class="mt-1 text-xs text-neutral-600">Create reusable templates for common replies</p>
57
+ </div>
58
+ <div v-for="resp in responses" :key="resp.id" class="rounded-xl border border-white/[0.06] bg-neutral-900/60 p-4 transition-colors hover:bg-gray-900/80">
45
59
  <div class="mb-2 flex items-center justify-between">
46
- <div>
47
- <span class="font-medium text-gray-900">{{ resp.title }}</span>
48
- <span v-if="resp.category" class="ml-2 rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">{{ resp.category }}</span>
49
- </div>
50
- <div>
51
- <button @click="destroy(resp.id)" class="text-sm text-red-600 hover:text-red-900">Delete</button>
60
+ <div class="flex items-center gap-2">
61
+ <span class="font-medium text-neutral-200">{{ resp.title }}</span>
62
+ <span v-if="resp.category" class="rounded-md bg-white/[0.06] px-2 py-0.5 text-xs text-neutral-400 ring-1 ring-white/[0.06]">{{ resp.category }}</span>
52
63
  </div>
64
+ <button @click="destroy(resp.id)" class="text-sm text-rose-400 hover:text-rose-300">Delete</button>
53
65
  </div>
54
- <p class="text-sm text-gray-600">{{ resp.body }}</p>
66
+ <p class="text-sm text-neutral-400">{{ resp.body }}</p>
55
67
  </div>
56
68
  </div>
57
69
  </EscalatedLayout>