@escalated-dev/escalated 0.7.1 → 0.8.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.
- package/README.md +5 -1
- package/package.json +6 -3
- package/src/components/EscalatedLayout.vue +6 -0
- package/src/components/SkillTagManager.vue +26 -22
- package/src/composables/useI18n.js +102 -80
- package/src/locales/README.md +13 -0
- package/src/pages/Admin/Skills/Form.vue +144 -4
- package/src/pages/Admin/Skills/Index.vue +14 -3
- package/src/pages/Admin/Users/Index.vue +145 -0
- package/src/pages/Customer/Index.vue +56 -31
- package/src/plugin.js +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
</h1>
|
|
22
22
|
|
|
23
23
|
[](https://github.com/escalated-dev/escalated/actions/workflows/run-tests.yml)
|
|
24
|
+
[](https://app.fossa.com/projects/custom%2B62107%2Fgithub.com%2Fescalated-dev%2Fescalated?ref=badge_shield)
|
|
24
25
|
[](https://www.npmjs.com/package/@escalated-dev/escalated)
|
|
25
26
|
[](https://vuejs.org/)
|
|
26
27
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -343,13 +344,16 @@ Peer dependencies: `vue` ^3.3.0, `@inertiajs/vue3` ^1.0.0 || ^2.0.0
|
|
|
343
344
|
|
|
344
345
|
## Ecosystem
|
|
345
346
|
|
|
346
|
-
This is the shared frontend for the Escalated support ticket system.
|
|
347
|
+
This is the shared frontend for the Escalated support ticket system. Host framework packages and client SDKs available across the ecosystem:
|
|
347
348
|
|
|
348
349
|
- **[Escalated for Laravel](https://github.com/escalated-dev/escalated-laravel)** — Laravel Composer package
|
|
349
350
|
- **[Escalated for Rails](https://github.com/escalated-dev/escalated-rails)** — Ruby on Rails engine
|
|
350
351
|
- **[Escalated for Django](https://github.com/escalated-dev/escalated-django)** — Django reusable app
|
|
351
352
|
- **[Escalated for AdonisJS](https://github.com/escalated-dev/escalated-adonis)** — AdonisJS v6 package
|
|
353
|
+
- **[Escalated for WordPress](https://github.com/escalated-dev/escalated-wordpress)** — WordPress plugin
|
|
352
354
|
- **[Escalated for Filament](https://github.com/escalated-dev/escalated-filament)** — Filament v3 admin panel plugin
|
|
355
|
+
- **[Escalated for React Native](https://github.com/escalated-dev/escalated-react-native)** — React Native SDK
|
|
356
|
+
- **[Escalated for Flutter](https://github.com/escalated-dev/escalated-flutter)** — Flutter SDK
|
|
353
357
|
- **[Shared Frontend](https://github.com/escalated-dev/escalated)** — Vue 3 + Inertia.js UI components (you are here)
|
|
354
358
|
|
|
355
359
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escalated-dev/escalated",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Vue 3 + Inertia.js UI components for Escalated \u00e2\u20ac\u201d the embeddable support ticket system",
|
|
5
5
|
"author": "Escalated Dev <hello@escalated.dev>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
"@inertiajs/vue3": "^1.0.0 || ^2.0.0 || ^3.0.0",
|
|
33
33
|
"vue": "^3.3.0"
|
|
34
34
|
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@escalated-dev/locale": "github:escalated-dev/escalated-locale#v0.1.2"
|
|
37
|
+
},
|
|
35
38
|
"scripts": {
|
|
36
39
|
"test": "vitest run",
|
|
37
40
|
"lint": "eslint src/ --max-warnings 0",
|
|
@@ -66,7 +69,7 @@
|
|
|
66
69
|
"eslint": "^10.0.1",
|
|
67
70
|
"eslint-plugin-storybook": "^10.2.12",
|
|
68
71
|
"eslint-plugin-vue": "^10.8.0",
|
|
69
|
-
"happy-dom": "^
|
|
72
|
+
"happy-dom": "^20.9.0",
|
|
70
73
|
"husky": "^9.1.7",
|
|
71
74
|
"lint-staged": "^16.2.7",
|
|
72
75
|
"playwright": "^1.50.0",
|
|
@@ -76,6 +79,6 @@
|
|
|
76
79
|
"tailwindcss": "^3.4.0",
|
|
77
80
|
"vitest": "^2.0.0",
|
|
78
81
|
"vue": "^3.5.0",
|
|
79
|
-
"wait-on": "^
|
|
82
|
+
"wait-on": "^9.0.5"
|
|
80
83
|
}
|
|
81
84
|
}
|
|
@@ -128,6 +128,12 @@ const adminLinks = computed(() => {
|
|
|
128
128
|
icon: 'M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z',
|
|
129
129
|
position: 84,
|
|
130
130
|
},
|
|
131
|
+
{
|
|
132
|
+
href: `${p}/admin/users`,
|
|
133
|
+
label: 'Users',
|
|
134
|
+
icon: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z',
|
|
135
|
+
position: 83,
|
|
136
|
+
},
|
|
131
137
|
{
|
|
132
138
|
href: `${p}/admin/capacity`,
|
|
133
139
|
label: 'Capacity',
|
|
@@ -2,31 +2,35 @@
|
|
|
2
2
|
import { ref, computed } from 'vue';
|
|
3
3
|
|
|
4
4
|
const props = defineProps({
|
|
5
|
-
|
|
5
|
+
items: { type: Array, default: () => [] },
|
|
6
6
|
modelValue: { type: Array, default: () => [] },
|
|
7
|
+
addLabel: { type: String, default: 'Add Item' },
|
|
8
|
+
emptyLabel: { type: String, default: 'No items selected' },
|
|
9
|
+
itemLabelKey: { type: String, default: 'name' },
|
|
10
|
+
itemValueKey: { type: String, default: 'id' },
|
|
7
11
|
});
|
|
8
12
|
|
|
9
13
|
const emit = defineEmits(['update:modelValue']);
|
|
10
14
|
|
|
11
15
|
const showDropdown = ref(false);
|
|
12
16
|
|
|
13
|
-
const
|
|
14
|
-
return props.
|
|
17
|
+
const selectedItems = computed(() => {
|
|
18
|
+
return props.items.filter((item) => props.modelValue.includes(item[props.itemValueKey]));
|
|
15
19
|
});
|
|
16
20
|
|
|
17
|
-
const
|
|
18
|
-
return props.
|
|
21
|
+
const unselectedItems = computed(() => {
|
|
22
|
+
return props.items.filter((item) => !props.modelValue.includes(item[props.itemValueKey]));
|
|
19
23
|
});
|
|
20
24
|
|
|
21
|
-
function
|
|
22
|
-
emit('update:modelValue', [...props.modelValue,
|
|
25
|
+
function addItem(value) {
|
|
26
|
+
emit('update:modelValue', [...props.modelValue, value]);
|
|
23
27
|
showDropdown.value = false;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
function
|
|
30
|
+
function removeItem(value) {
|
|
27
31
|
emit(
|
|
28
32
|
'update:modelValue',
|
|
29
|
-
props.modelValue.filter((id) => id !==
|
|
33
|
+
props.modelValue.filter((id) => id !== value),
|
|
30
34
|
);
|
|
31
35
|
}
|
|
32
36
|
</script>
|
|
@@ -36,42 +40,42 @@ function removeSkill(skillId) {
|
|
|
36
40
|
<!-- Selected skill tags -->
|
|
37
41
|
<div class="flex flex-wrap gap-1.5">
|
|
38
42
|
<span
|
|
39
|
-
v-for="
|
|
40
|
-
:key="
|
|
43
|
+
v-for="item in selectedItems"
|
|
44
|
+
:key="item[itemValueKey]"
|
|
41
45
|
class="inline-flex items-center gap-1 rounded-md bg-cyan-500/15 px-2 py-1 text-xs font-medium text-cyan-300 ring-1 ring-cyan-500/20"
|
|
42
46
|
>
|
|
43
|
-
{{
|
|
47
|
+
{{ item[itemLabelKey] }}
|
|
44
48
|
<button
|
|
45
|
-
aria-label="Remove
|
|
49
|
+
:aria-label="`Remove ${item[itemLabelKey]}`"
|
|
46
50
|
class="ml-0.5 text-cyan-400 hover:text-cyan-200"
|
|
47
|
-
@click="
|
|
51
|
+
@click="removeItem(item[itemValueKey])"
|
|
48
52
|
>
|
|
49
53
|
×
|
|
50
54
|
</button>
|
|
51
55
|
</span>
|
|
52
|
-
<span v-if="!
|
|
56
|
+
<span v-if="!selectedItems.length" class="text-xs text-neutral-500">{{ emptyLabel }}</span>
|
|
53
57
|
</div>
|
|
54
58
|
|
|
55
59
|
<!-- Add button / dropdown -->
|
|
56
60
|
<div class="relative">
|
|
57
61
|
<button
|
|
58
|
-
v-if="
|
|
62
|
+
v-if="unselectedItems.length"
|
|
59
63
|
class="rounded-lg border border-dashed border-white/[0.08] px-2 py-1 text-xs text-neutral-400 transition-colors hover:border-white/[0.15] hover:text-neutral-300"
|
|
60
64
|
@click="showDropdown = !showDropdown"
|
|
61
65
|
>
|
|
62
|
-
+
|
|
66
|
+
+ {{ addLabel }}
|
|
63
67
|
</button>
|
|
64
68
|
<div
|
|
65
|
-
v-if="showDropdown &&
|
|
69
|
+
v-if="showDropdown && unselectedItems.length"
|
|
66
70
|
class="absolute left-0 top-full z-10 mt-1 max-h-40 w-48 overflow-y-auto rounded-lg border border-white/[0.06] bg-neutral-900 py-1 shadow-xl"
|
|
67
71
|
>
|
|
68
72
|
<button
|
|
69
|
-
v-for="
|
|
70
|
-
:key="
|
|
73
|
+
v-for="item in unselectedItems"
|
|
74
|
+
:key="item[itemValueKey]"
|
|
71
75
|
class="w-full px-3 py-1.5 text-left text-xs text-neutral-300 transition-colors hover:bg-white/[0.04]"
|
|
72
|
-
@click="
|
|
76
|
+
@click="addItem(item[itemValueKey])"
|
|
73
77
|
>
|
|
74
|
-
{{
|
|
78
|
+
{{ item[itemLabelKey] }}
|
|
75
79
|
</button>
|
|
76
80
|
</div>
|
|
77
81
|
</div>
|
|
@@ -1,80 +1,102 @@
|
|
|
1
|
-
import { ref, computed } from 'vue';
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
1
|
+
import { ref, computed } from 'vue';
|
|
2
|
+
import overrideLocales from '../locales/index.js';
|
|
3
|
+
|
|
4
|
+
// Central translations published from escalated-dev/escalated-locale.
|
|
5
|
+
// This is the BASE layer. Local src/locales/*.json files in this repo are
|
|
6
|
+
// loaded as OVERRIDES and win over the central source for any matching key.
|
|
7
|
+
//
|
|
8
|
+
// Until @escalated-dev/locale v0.1.0 ships its consolidated strings, this
|
|
9
|
+
// import resolves to a stub object with the same shape. Local overrides
|
|
10
|
+
// (src/locales/) preserve the current behavior so components keep rendering
|
|
11
|
+
// the strings they always rendered. After Codex finishes consolidating
|
|
12
|
+
// translations into the central package, the local override files can be
|
|
13
|
+
// trimmed (or deleted entirely) and the central source becomes authoritative.
|
|
14
|
+
import centralLocales from '@escalated-dev/locale';
|
|
15
|
+
|
|
16
|
+
const currentLocale = ref('en');
|
|
17
|
+
const customMessages = ref({});
|
|
18
|
+
|
|
19
|
+
function resolve(obj, path) {
|
|
20
|
+
return path.split('.').reduce((acc, key) => acc?.[key], obj);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function applyReplacements(str, replacements) {
|
|
24
|
+
if (!replacements) return str;
|
|
25
|
+
return Object.entries(replacements).reduce(
|
|
26
|
+
(result, [key, value]) => result.replace(new RegExp(`:${key}`, 'g'), value),
|
|
27
|
+
str,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setLocale(locale) {
|
|
32
|
+
currentLocale.value = locale;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getLocale() {
|
|
36
|
+
return currentLocale.value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function mergeMessages(locale, messages) {
|
|
40
|
+
customMessages.value = {
|
|
41
|
+
...customMessages.value,
|
|
42
|
+
[locale]: deepMerge(customMessages.value[locale] || {}, messages),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function deepMerge(target, source) {
|
|
47
|
+
const result = { ...target };
|
|
48
|
+
for (const key of Object.keys(source)) {
|
|
49
|
+
if (
|
|
50
|
+
source[key] &&
|
|
51
|
+
typeof source[key] === 'object' &&
|
|
52
|
+
!Array.isArray(source[key]) &&
|
|
53
|
+
target[key] &&
|
|
54
|
+
typeof target[key] === 'object'
|
|
55
|
+
) {
|
|
56
|
+
result[key] = deepMerge(target[key], source[key]);
|
|
57
|
+
} else {
|
|
58
|
+
result[key] = source[key];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useI18n() {
|
|
65
|
+
const locale = computed(() => currentLocale.value);
|
|
66
|
+
|
|
67
|
+
function t(key, replacements) {
|
|
68
|
+
const lang = currentLocale.value;
|
|
69
|
+
|
|
70
|
+
// 1. Host-app supplied messages for the current locale (highest priority).
|
|
71
|
+
const custom = resolve(customMessages.value[lang], key);
|
|
72
|
+
if (typeof custom === 'string') return applyReplacements(custom, replacements);
|
|
73
|
+
|
|
74
|
+
// 2. Local plugin overrides for the current locale (src/locales/*.json).
|
|
75
|
+
// These exist to let this repo win over the central source for any
|
|
76
|
+
// string the plugin needs to phrase differently.
|
|
77
|
+
const override = resolve(overrideLocales[lang], key);
|
|
78
|
+
if (typeof override === 'string') return applyReplacements(override, replacements);
|
|
79
|
+
|
|
80
|
+
// 3. Central source for the current locale (@escalated-dev/locale).
|
|
81
|
+
// This is the canonical base layer.
|
|
82
|
+
const central = resolve(centralLocales?.[lang], key);
|
|
83
|
+
if (typeof central === 'string') return applyReplacements(central, replacements);
|
|
84
|
+
|
|
85
|
+
// 4. English fallbacks, in the same priority order.
|
|
86
|
+
if (lang !== 'en') {
|
|
87
|
+
const customEn = resolve(customMessages.value.en, key);
|
|
88
|
+
if (typeof customEn === 'string') return applyReplacements(customEn, replacements);
|
|
89
|
+
|
|
90
|
+
const overrideEn = resolve(overrideLocales.en, key);
|
|
91
|
+
if (typeof overrideEn === 'string') return applyReplacements(overrideEn, replacements);
|
|
92
|
+
|
|
93
|
+
const centralEn = resolve(centralLocales?.en, key);
|
|
94
|
+
if (typeof centralEn === 'string') return applyReplacements(centralEn, replacements);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Return raw key
|
|
98
|
+
return key;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { t, locale };
|
|
102
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Local locale overrides
|
|
2
|
+
|
|
3
|
+
The JSON files in this directory (`en.json`, `de.json`, …) are **local overrides** that win over the central source published by [`@escalated-dev/locale`](https://github.com/escalated-dev/escalated-locale).
|
|
4
|
+
|
|
5
|
+
The composable in `src/composables/useI18n.js` resolves keys in this order:
|
|
6
|
+
|
|
7
|
+
1. Host-app `customMessages` for the current locale
|
|
8
|
+
2. Local override (these JSON files) for the current locale
|
|
9
|
+
3. Central source (`@escalated-dev/locale`) for the current locale
|
|
10
|
+
4. Same three layers, but English fallback
|
|
11
|
+
5. Raw key
|
|
12
|
+
|
|
13
|
+
After the central package is populated with the canonical strings, files here can be trimmed to contain only the keys this plugin needs to phrase differently from the central source. Keys not present locally fall through to the central source unchanged.
|
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { computed } from 'vue';
|
|
2
3
|
import EscalatedLayout from '../../../components/EscalatedLayout.vue';
|
|
3
|
-
import
|
|
4
|
+
import SkillTagManager from '../../../components/SkillTagManager.vue';
|
|
5
|
+
import { Link, useForm } from '@inertiajs/vue3';
|
|
4
6
|
|
|
5
7
|
const props = defineProps({
|
|
6
8
|
skill: { type: Object, default: null },
|
|
9
|
+
availableAgents: { type: Array, default: () => [] },
|
|
10
|
+
availableTags: { type: Array, default: () => [] },
|
|
11
|
+
availableDepartments: { type: Array, default: () => [] },
|
|
7
12
|
});
|
|
8
13
|
|
|
9
14
|
const form = useForm({
|
|
10
15
|
name: props.skill?.name || '',
|
|
16
|
+
routing_tag_ids: props.skill?.routing_tag_ids || [],
|
|
17
|
+
routing_department_ids: props.skill?.routing_department_ids || [],
|
|
18
|
+
agents: props.skill?.agents?.length ? props.skill.agents.map((agent) => ({ ...agent })) : [],
|
|
11
19
|
});
|
|
12
20
|
|
|
21
|
+
const unassignedAgents = computed(() =>
|
|
22
|
+
props.availableAgents.filter((agent) => !form.agents.some((entry) => entry.user_id === agent.id)),
|
|
23
|
+
);
|
|
24
|
+
|
|
13
25
|
function submit() {
|
|
14
26
|
if (props.skill) {
|
|
15
27
|
form.put(route('escalated.admin.skills.update', props.skill.id));
|
|
@@ -17,11 +29,27 @@ function submit() {
|
|
|
17
29
|
form.post(route('escalated.admin.skills.store'));
|
|
18
30
|
}
|
|
19
31
|
}
|
|
32
|
+
|
|
33
|
+
function addAgent() {
|
|
34
|
+
const nextAgent = unassignedAgents.value[0];
|
|
35
|
+
if (!nextAgent) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
form.agents.push({ user_id: nextAgent.id, proficiency: 3 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function removeAgent(index) {
|
|
43
|
+
form.agents.splice(index, 1);
|
|
44
|
+
}
|
|
20
45
|
</script>
|
|
21
46
|
|
|
22
47
|
<template>
|
|
23
48
|
<EscalatedLayout :title="skill ? 'Edit Skill' : 'Create Skill'">
|
|
24
|
-
<form
|
|
49
|
+
<form
|
|
50
|
+
class="mx-auto max-w-4xl space-y-5 rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6"
|
|
51
|
+
@submit.prevent="submit"
|
|
52
|
+
>
|
|
25
53
|
<div>
|
|
26
54
|
<label class="block text-sm font-medium text-[var(--esc-panel-text-secondary)]">Name</label>
|
|
27
55
|
<input
|
|
@@ -33,6 +61,118 @@ function submit() {
|
|
|
33
61
|
<p v-if="form.errors.name" class="mt-1 text-xs text-rose-400">{{ form.errors.name }}</p>
|
|
34
62
|
</div>
|
|
35
63
|
|
|
64
|
+
<div class="grid gap-5 lg:grid-cols-2">
|
|
65
|
+
<div class="space-y-3 rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface-alt)] p-4">
|
|
66
|
+
<div>
|
|
67
|
+
<h2 class="text-sm font-semibold text-[var(--esc-panel-text-secondary)]">Routing Rules</h2>
|
|
68
|
+
<p class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
|
|
69
|
+
Match tickets by tag or department to require this skill during auto-assignment.
|
|
70
|
+
</p>
|
|
71
|
+
</div>
|
|
72
|
+
<div>
|
|
73
|
+
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-[var(--esc-panel-text-muted)]">
|
|
74
|
+
Required Tags
|
|
75
|
+
</label>
|
|
76
|
+
<SkillTagManager
|
|
77
|
+
v-model="form.routing_tag_ids"
|
|
78
|
+
:items="availableTags"
|
|
79
|
+
add-label="Add Tag"
|
|
80
|
+
empty-label="No ticket tags mapped"
|
|
81
|
+
/>
|
|
82
|
+
<p v-if="form.errors.routing_tag_ids" class="mt-1 text-xs text-rose-400">
|
|
83
|
+
{{ form.errors.routing_tag_ids }}
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
<div>
|
|
87
|
+
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-[var(--esc-panel-text-muted)]">
|
|
88
|
+
Departments
|
|
89
|
+
</label>
|
|
90
|
+
<SkillTagManager
|
|
91
|
+
v-model="form.routing_department_ids"
|
|
92
|
+
:items="availableDepartments"
|
|
93
|
+
add-label="Add Department"
|
|
94
|
+
empty-label="No departments mapped"
|
|
95
|
+
/>
|
|
96
|
+
<p v-if="form.errors.routing_department_ids" class="mt-1 text-xs text-rose-400">
|
|
97
|
+
{{ form.errors.routing_department_ids }}
|
|
98
|
+
</p>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div class="space-y-3 rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface-alt)] p-4">
|
|
103
|
+
<div class="flex items-start justify-between gap-3">
|
|
104
|
+
<div>
|
|
105
|
+
<h2 class="text-sm font-semibold text-[var(--esc-panel-text-secondary)]">Agent Proficiency</h2>
|
|
106
|
+
<p class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
|
|
107
|
+
Assign eligible agents and tune their proficiency from 1 to 5.
|
|
108
|
+
</p>
|
|
109
|
+
</div>
|
|
110
|
+
<button
|
|
111
|
+
v-if="unassignedAgents.length"
|
|
112
|
+
type="button"
|
|
113
|
+
class="rounded-lg border border-dashed border-[var(--esc-panel-border-input)] px-3 py-1.5 text-xs font-medium text-[var(--esc-panel-text-secondary)] transition-colors hover:bg-[var(--esc-panel-hover)]"
|
|
114
|
+
@click="addAgent"
|
|
115
|
+
>
|
|
116
|
+
Add Agent
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div v-if="form.agents.length" class="space-y-3">
|
|
121
|
+
<div
|
|
122
|
+
v-for="(agent, index) in form.agents"
|
|
123
|
+
:key="`${agent.user_id}-${index}`"
|
|
124
|
+
class="grid gap-3 rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-3 md:grid-cols-[minmax(0,1fr)_110px_auto]"
|
|
125
|
+
>
|
|
126
|
+
<div>
|
|
127
|
+
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-[var(--esc-panel-text-muted)]">
|
|
128
|
+
Agent
|
|
129
|
+
</label>
|
|
130
|
+
<select
|
|
131
|
+
v-model="agent.user_id"
|
|
132
|
+
class="w-full rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)] px-3 py-2 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)]"
|
|
133
|
+
>
|
|
134
|
+
<option
|
|
135
|
+
v-for="option in [...unassignedAgents, availableAgents.find((entry) => entry.id === agent.user_id)].filter(Boolean)"
|
|
136
|
+
:key="option.id"
|
|
137
|
+
:value="option.id"
|
|
138
|
+
>
|
|
139
|
+
{{ option.name }} · {{ option.email }}
|
|
140
|
+
</option>
|
|
141
|
+
</select>
|
|
142
|
+
</div>
|
|
143
|
+
<div>
|
|
144
|
+
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-[var(--esc-panel-text-muted)]">
|
|
145
|
+
Proficiency
|
|
146
|
+
</label>
|
|
147
|
+
<select
|
|
148
|
+
v-model="agent.proficiency"
|
|
149
|
+
class="w-full rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)] px-3 py-2 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)]"
|
|
150
|
+
>
|
|
151
|
+
<option :value="1">1</option>
|
|
152
|
+
<option :value="2">2</option>
|
|
153
|
+
<option :value="3">3</option>
|
|
154
|
+
<option :value="4">4</option>
|
|
155
|
+
<option :value="5">5</option>
|
|
156
|
+
</select>
|
|
157
|
+
</div>
|
|
158
|
+
<div class="flex items-end">
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
class="rounded-lg px-3 py-2 text-sm font-medium text-rose-300 transition-colors hover:bg-rose-500/10"
|
|
162
|
+
@click="removeAgent(index)"
|
|
163
|
+
>
|
|
164
|
+
Remove
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div v-else class="rounded-lg border border-dashed border-[var(--esc-panel-border)] px-4 py-6 text-center text-sm text-[var(--esc-panel-text-muted)]">
|
|
171
|
+
No agents assigned yet.
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
36
176
|
<div class="flex gap-3">
|
|
37
177
|
<button
|
|
38
178
|
type="submit"
|
|
@@ -41,12 +181,12 @@ function submit() {
|
|
|
41
181
|
>
|
|
42
182
|
{{ skill ? 'Update' : 'Create' }}
|
|
43
183
|
</button>
|
|
44
|
-
<
|
|
184
|
+
<Link
|
|
45
185
|
:href="route('escalated.admin.skills.index')"
|
|
46
186
|
class="rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] px-4 py-2 text-sm font-medium text-[var(--esc-panel-text-secondary)] transition-colors hover:bg-[var(--esc-panel-hover)]"
|
|
47
187
|
>
|
|
48
188
|
Cancel
|
|
49
|
-
</
|
|
189
|
+
</Link>
|
|
50
190
|
</div>
|
|
51
191
|
</form>
|
|
52
192
|
</EscalatedLayout>
|
|
@@ -33,7 +33,12 @@ function destroy(id) {
|
|
|
33
33
|
<th
|
|
34
34
|
class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
|
|
35
35
|
>
|
|
36
|
-
|
|
36
|
+
Routing
|
|
37
|
+
</th>
|
|
38
|
+
<th
|
|
39
|
+
class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
|
|
40
|
+
>
|
|
41
|
+
Updated
|
|
37
42
|
</th>
|
|
38
43
|
<th
|
|
39
44
|
class="px-4 py-3 text-right text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
|
|
@@ -44,7 +49,7 @@ function destroy(id) {
|
|
|
44
49
|
</thead>
|
|
45
50
|
<tbody class="divide-y divide-[var(--esc-panel-border)]">
|
|
46
51
|
<tr v-if="!skills?.length">
|
|
47
|
-
<td colspan="
|
|
52
|
+
<td colspan="4" class="px-4 py-12 text-center">
|
|
48
53
|
<svg
|
|
49
54
|
class="mx-auto mb-3 h-8 w-8 text-[var(--esc-panel-text-muted)]"
|
|
50
55
|
fill="none"
|
|
@@ -73,7 +78,13 @@ function destroy(id) {
|
|
|
73
78
|
{{ skill.name }}
|
|
74
79
|
</td>
|
|
75
80
|
<td class="px-4 py-3 text-sm text-[var(--esc-panel-text-tertiary)]">
|
|
76
|
-
{{ skill.agents_count }}
|
|
81
|
+
<div>{{ skill.agents_count }} agents</div>
|
|
82
|
+
<div class="text-xs text-[var(--esc-panel-text-muted)]">
|
|
83
|
+
{{ skill.routing_tags_count }} tags / {{ skill.routing_departments_count }} departments
|
|
84
|
+
</div>
|
|
85
|
+
</td>
|
|
86
|
+
<td class="px-4 py-3 text-sm text-[var(--esc-panel-text-tertiary)]">
|
|
87
|
+
{{ skill.updated_at ? new Date(skill.updated_at).toLocaleDateString() : '-' }}
|
|
77
88
|
</td>
|
|
78
89
|
<td class="px-4 py-3 text-right text-sm">
|
|
79
90
|
<Link
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref } from 'vue';
|
|
3
|
+
import { router } from '@inertiajs/vue3';
|
|
4
|
+
import EscalatedLayout from '../../../components/EscalatedLayout.vue';
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
users: Object,
|
|
8
|
+
filters: { type: Object, default: () => ({}) },
|
|
9
|
+
currentUserId: { type: [Number, String], default: null },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const search = ref(props.filters?.search ?? '');
|
|
13
|
+
|
|
14
|
+
function applySearch() {
|
|
15
|
+
router.get(
|
|
16
|
+
route('escalated.admin.users.index'),
|
|
17
|
+
{ search: search.value },
|
|
18
|
+
{ preserveState: true, preserveScroll: true },
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toggleRole(user, role) {
|
|
23
|
+
const value = role === 'admin' ? !user.is_admin : !user.is_agent;
|
|
24
|
+
router.patch(route('escalated.admin.users.role', user.id), { role, value }, { preserveScroll: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isSelf(user) {
|
|
28
|
+
return props.currentUserId !== null && String(user.id) === String(props.currentUserId);
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<EscalatedLayout title="Users">
|
|
34
|
+
<div class="mb-4 flex items-center justify-between gap-4">
|
|
35
|
+
<form class="flex-1 max-w-sm" @submit.prevent="applySearch">
|
|
36
|
+
<input
|
|
37
|
+
v-model="search"
|
|
38
|
+
type="search"
|
|
39
|
+
placeholder="Search by name or email"
|
|
40
|
+
class="w-full rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] px-3 py-2 text-sm text-[var(--esc-panel-text)] placeholder-[var(--esc-panel-text-muted)] focus:border-[var(--esc-panel-accent)] focus:outline-none focus:ring-1 focus:ring-[var(--esc-panel-accent)]"
|
|
41
|
+
/>
|
|
42
|
+
</form>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="overflow-hidden rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)]">
|
|
46
|
+
<table class="min-w-full divide-y divide-[var(--esc-panel-border)]">
|
|
47
|
+
<thead>
|
|
48
|
+
<tr class="bg-[var(--esc-panel-hover)]">
|
|
49
|
+
<th
|
|
50
|
+
class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
|
|
51
|
+
>
|
|
52
|
+
User
|
|
53
|
+
</th>
|
|
54
|
+
<th
|
|
55
|
+
class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
|
|
56
|
+
>
|
|
57
|
+
Email
|
|
58
|
+
</th>
|
|
59
|
+
<th
|
|
60
|
+
class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
|
|
61
|
+
>
|
|
62
|
+
Admin
|
|
63
|
+
</th>
|
|
64
|
+
<th
|
|
65
|
+
class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
|
|
66
|
+
>
|
|
67
|
+
Agent
|
|
68
|
+
</th>
|
|
69
|
+
</tr>
|
|
70
|
+
</thead>
|
|
71
|
+
<tbody class="divide-y divide-[var(--esc-panel-border)]">
|
|
72
|
+
<tr v-if="!users?.data?.length">
|
|
73
|
+
<td colspan="4" class="px-4 py-12 text-center text-sm text-[var(--esc-panel-text-muted)]">
|
|
74
|
+
No users found.
|
|
75
|
+
</td>
|
|
76
|
+
</tr>
|
|
77
|
+
<tr v-for="user in users?.data || []" :key="user.id" class="hover:bg-[var(--esc-panel-hover)]">
|
|
78
|
+
<td class="whitespace-nowrap px-4 py-3 text-sm text-[var(--esc-panel-text)]">
|
|
79
|
+
<span>{{ user.name || '—' }}</span>
|
|
80
|
+
<span
|
|
81
|
+
v-if="isSelf(user)"
|
|
82
|
+
class="ml-2 rounded-full bg-[var(--esc-panel-accent)]/15 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--esc-panel-accent)]"
|
|
83
|
+
>
|
|
84
|
+
You
|
|
85
|
+
</span>
|
|
86
|
+
</td>
|
|
87
|
+
<td class="whitespace-nowrap px-4 py-3 text-sm text-[var(--esc-panel-text-muted)]">
|
|
88
|
+
{{ user.email }}
|
|
89
|
+
</td>
|
|
90
|
+
<td class="px-4 py-3">
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
:disabled="isSelf(user) && user.is_admin"
|
|
94
|
+
:title="isSelf(user) && user.is_admin ? 'You cannot remove your own admin role.' : ''"
|
|
95
|
+
class="inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
96
|
+
:class="user.is_admin ? 'bg-indigo-600' : 'bg-gray-300'"
|
|
97
|
+
@click="toggleRole(user, 'admin')"
|
|
98
|
+
>
|
|
99
|
+
<span
|
|
100
|
+
class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform"
|
|
101
|
+
:class="user.is_admin ? 'translate-x-6' : 'translate-x-1'"
|
|
102
|
+
/>
|
|
103
|
+
</button>
|
|
104
|
+
</td>
|
|
105
|
+
<td class="px-4 py-3">
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
class="inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
|
109
|
+
:class="user.is_agent ? 'bg-indigo-600' : 'bg-gray-300'"
|
|
110
|
+
@click="toggleRole(user, 'agent')"
|
|
111
|
+
>
|
|
112
|
+
<span
|
|
113
|
+
class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform"
|
|
114
|
+
:class="user.is_agent ? 'translate-x-6' : 'translate-x-1'"
|
|
115
|
+
/>
|
|
116
|
+
</button>
|
|
117
|
+
</td>
|
|
118
|
+
</tr>
|
|
119
|
+
</tbody>
|
|
120
|
+
</table>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div v-if="users?.links?.length > 3" class="mt-4 flex items-center justify-end gap-1">
|
|
124
|
+
<template v-for="(link, idx) in users.links" :key="idx">
|
|
125
|
+
<button
|
|
126
|
+
v-if="link.url"
|
|
127
|
+
type="button"
|
|
128
|
+
class="rounded-md px-3 py-1 text-sm"
|
|
129
|
+
:class="
|
|
130
|
+
link.active
|
|
131
|
+
? 'bg-[var(--esc-panel-accent)] text-white'
|
|
132
|
+
: 'text-[var(--esc-panel-text-muted)] hover:bg-[var(--esc-panel-hover)]'
|
|
133
|
+
"
|
|
134
|
+
@click="router.get(link.url, {}, { preserveState: true, preserveScroll: true })"
|
|
135
|
+
v-html="link.label"
|
|
136
|
+
/>
|
|
137
|
+
<span
|
|
138
|
+
v-else
|
|
139
|
+
class="rounded-md px-3 py-1 text-sm text-[var(--esc-panel-text-muted)]/40"
|
|
140
|
+
v-html="link.label"
|
|
141
|
+
/>
|
|
142
|
+
</template>
|
|
143
|
+
</div>
|
|
144
|
+
</EscalatedLayout>
|
|
145
|
+
</template>
|
|
@@ -1,31 +1,56 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import EscalatedLayout from '../../components/EscalatedLayout.vue';
|
|
4
|
+
import TicketList from '../../components/TicketList.vue';
|
|
5
|
+
import TicketFilters from '../../components/TicketFilters.vue';
|
|
6
|
+
import PluginSlot from '../../components/PluginSlot.vue';
|
|
7
|
+
import { Link, usePage } from '@inertiajs/vue3';
|
|
8
|
+
import { usePluginExtensions } from '../../composables/usePluginExtensions';
|
|
9
|
+
|
|
10
|
+
defineProps({
|
|
11
|
+
tickets: Object,
|
|
12
|
+
filters: Object,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const { getPageComponents } = usePluginExtensions();
|
|
16
|
+
const page = usePage();
|
|
17
|
+
const isAdmin = computed(() => !!page.props.escalated?.is_admin);
|
|
18
|
+
const isAgent = computed(() => !!page.props.escalated?.is_agent);
|
|
19
|
+
const staffPanelHref = computed(() => {
|
|
20
|
+
const prefix = page.props.escalated?.prefix || 'support';
|
|
21
|
+
const base = prefix.startsWith('/') ? prefix : `/${prefix}`;
|
|
22
|
+
return isAdmin.value ? `${base}/admin/tickets` : `${base}/agent/tickets`;
|
|
23
|
+
});
|
|
24
|
+
const staffPanelLabel = computed(() => (isAdmin.value ? 'Open admin panel' : 'Open agent panel'));
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<EscalatedLayout title="My Tickets">
|
|
29
|
+
<PluginSlot slot="customer.index.header" :components="getPageComponents('customer.index', 'header')" />
|
|
30
|
+
<div
|
|
31
|
+
v-if="isAdmin || isAgent"
|
|
32
|
+
class="mb-4 flex items-center justify-between gap-4 rounded-lg border border-indigo-200 bg-indigo-50 px-4 py-3 text-sm text-indigo-900"
|
|
33
|
+
>
|
|
34
|
+
<div>
|
|
35
|
+
<span class="font-medium">You're viewing your personal tickets.</span>
|
|
36
|
+
<span class="text-indigo-800/80"> Customer-submitted tickets live in the staff panel. </span>
|
|
37
|
+
</div>
|
|
38
|
+
<a
|
|
39
|
+
:href="staffPanelHref"
|
|
40
|
+
class="shrink-0 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700"
|
|
41
|
+
>
|
|
42
|
+
{{ staffPanelLabel }}
|
|
43
|
+
</a>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="mb-6 flex items-center justify-between">
|
|
46
|
+
<TicketFilters :filters="filters" :route="route('escalated.customer.tickets.index')" />
|
|
47
|
+
<Link
|
|
48
|
+
:href="route('escalated.customer.tickets.create')"
|
|
49
|
+
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
|
|
50
|
+
>
|
|
51
|
+
New Ticket
|
|
52
|
+
</Link>
|
|
53
|
+
</div>
|
|
54
|
+
<TicketList :tickets="tickets" route-prefix="escalated.customer.tickets" />
|
|
55
|
+
</EscalatedLayout>
|
|
56
|
+
</template>
|
package/src/plugin.js
CHANGED
|
@@ -94,7 +94,7 @@ function darken(hex, percent) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
function scaleBorderRadius(radius, factor) {
|
|
97
|
-
const match = radius.match(/^(
|
|
97
|
+
const match = radius.match(/^(\d+(?:\.\d+)?|\.\d+)([a-z%]*)$/i);
|
|
98
98
|
if (!match) return radius;
|
|
99
99
|
return `${(parseFloat(match[1]) * factor).toFixed(2)}${match[2] || 'rem'}`;
|
|
100
100
|
}
|