@escalated-dev/escalated 0.9.0 → 0.11.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.
- package/README.md +13 -5
- package/package.json +26 -26
- package/src/components/EscalatedLayout.vue +9 -0
- package/src/pages/Admin/Automations/Form.vue +280 -0
- package/src/pages/Admin/Automations/Index.vue +230 -0
- package/src/pages/Admin/Plugins/Index.vue +10 -2
- package/src/pages/Admin/Settings/DatabaseConnection.vue +228 -0
- package/src/pages/Admin/Settings/PublicTickets.vue +163 -0
- package/src/pages/Admin/Webhooks/DeliveryLog.vue +1 -2
- package/src/pages/Admin/Workflows/Form.vue +24 -0
- package/src/pages/Plugin/Page.vue +61 -0
package/README.md
CHANGED
|
@@ -15,19 +15,23 @@
|
|
|
15
15
|
<a href="docs/translations/README.zh-CN.md">简体中文</a>
|
|
16
16
|
</p>
|
|
17
17
|
|
|
18
|
-
<h1>
|
|
18
|
+
<h1 align="center">
|
|
19
19
|
<img src="https://escalated.dev/apple-touch-icon.png" width="28" style="vertical-align:middle;" />
|
|
20
20
|
Escalated
|
|
21
21
|
</h1>
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
[](https://www.npmjs.com/package/@escalated-dev/escalated)
|
|
23
|
+
<div align="center">
|
|
24
|
+
|
|
25
|
+
[](https://github.com/escalated-dev/escalated/actions/workflows/run-tests.yml)[](https://www.npmjs.com/package/@escalated-dev/escalated)
|
|
26
26
|
[](https://vuejs.org/)
|
|
27
27
|
[](https://opensource.org/licenses/MIT)
|
|
28
28
|
|
|
29
|
+
</div>
|
|
30
|
+
|
|
29
31
|
<p align="center">
|
|
30
|
-
<
|
|
32
|
+
<a href="https://escalated.dev">
|
|
33
|
+
<img src=".github/og.png" alt="Escalated — support tickets, built into your app" width="840" />
|
|
34
|
+
</a>
|
|
31
35
|
</p>
|
|
32
36
|
|
|
33
37
|
Escalated is an embeddable support ticket system with SLA tracking, escalation rules, agent workflows, and a customer portal. This repo contains all the shared frontend assets (Vue 3 + Inertia.js) used across every supported backend framework.
|
|
@@ -36,6 +40,10 @@ Escalated is an embeddable support ticket system with SLA tracking, escalation r
|
|
|
36
40
|
|
|
37
41
|
**You don't install this package directly.** Start with the backend package for your framework — it handles everything including pulling in these frontend assets.
|
|
38
42
|
|
|
43
|
+
<p align="center">
|
|
44
|
+
<img src=".github/profile/demo.gif" alt="Escalated demo: filing a ticket and replying from the agent inbox" width="800" />
|
|
45
|
+
</p>
|
|
46
|
+
|
|
39
47
|
## Features
|
|
40
48
|
|
|
41
49
|
- **Ticket splitting** — Split a reply into a new standalone ticket while preserving context
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escalated-dev/escalated",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Vue 3 + Inertia.js UI components for Escalated
|
|
3
|
+
"version": "0.11.1",
|
|
4
|
+
"description": "Vue 3 + Inertia.js UI components for Escalated — the embeddable support ticket system",
|
|
5
5
|
"author": "Escalated Dev <hello@escalated.dev>",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://github.com/escalated-dev/escalated",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"vue": "^3.3.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@escalated-dev/locale": "
|
|
36
|
+
"@escalated-dev/locale": "^0.1.8"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"test": "vitest run",
|
|
@@ -57,29 +57,29 @@
|
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@eslint/js": "^10.0.1",
|
|
60
|
-
"@inertiajs/vue3": "^3.
|
|
61
|
-
"@playwright/test": "^1.
|
|
62
|
-
"@storybook/addon-a11y": "^10.
|
|
63
|
-
"@storybook/addon-docs": "^10.
|
|
64
|
-
"@storybook/vue3-vite": "^10.
|
|
65
|
-
"@tailwindcss/postcss": "^4.3.
|
|
66
|
-
"@vitejs/plugin-vue": "^6.0.
|
|
67
|
-
"@vue/test-utils": "^2.
|
|
68
|
-
"autoprefixer": "^10.4
|
|
69
|
-
"concurrently": "^
|
|
70
|
-
"eslint": "^10.
|
|
71
|
-
"eslint-plugin-storybook": "^10.
|
|
72
|
-
"eslint-plugin-vue": "^10.
|
|
73
|
-
"happy-dom": "^20.
|
|
60
|
+
"@inertiajs/vue3": "^3.7.0",
|
|
61
|
+
"@playwright/test": "^1.62.1",
|
|
62
|
+
"@storybook/addon-a11y": "^10.6.0",
|
|
63
|
+
"@storybook/addon-docs": "^10.6.0",
|
|
64
|
+
"@storybook/vue3-vite": "^10.6.0",
|
|
65
|
+
"@tailwindcss/postcss": "^4.3.3",
|
|
66
|
+
"@vitejs/plugin-vue": "^6.0.8",
|
|
67
|
+
"@vue/test-utils": "^2.5.0",
|
|
68
|
+
"autoprefixer": "^10.5.4",
|
|
69
|
+
"concurrently": "^10.0.5",
|
|
70
|
+
"eslint": "^10.9.1",
|
|
71
|
+
"eslint-plugin-storybook": "^10.5.10",
|
|
72
|
+
"eslint-plugin-vue": "^10.10.0",
|
|
73
|
+
"happy-dom": "^20.14.0",
|
|
74
74
|
"husky": "^9.1.7",
|
|
75
|
-
"lint-staged": "^17.
|
|
76
|
-
"playwright": "^1.
|
|
77
|
-
"postcss": "^8.5.
|
|
78
|
-
"prettier": "^3.
|
|
79
|
-
"storybook": "^10.
|
|
80
|
-
"tailwindcss": "^4.3.
|
|
81
|
-
"vitest": "^
|
|
82
|
-
"vue": "^3.5.
|
|
83
|
-
"wait-on": "^9.0
|
|
75
|
+
"lint-staged": "^17.4.1",
|
|
76
|
+
"playwright": "^1.62.1",
|
|
77
|
+
"postcss": "^8.5.26",
|
|
78
|
+
"prettier": "^3.9.6",
|
|
79
|
+
"storybook": "^10.5.10",
|
|
80
|
+
"tailwindcss": "^4.3.3",
|
|
81
|
+
"vitest": "^5.0.0",
|
|
82
|
+
"vue": "^3.5.42",
|
|
83
|
+
"wait-on": "^9.1.0"
|
|
84
84
|
}
|
|
85
85
|
}
|
|
@@ -85,6 +85,15 @@ const adminLinks = computed(() => {
|
|
|
85
85
|
icon: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
|
86
86
|
position: 53,
|
|
87
87
|
},
|
|
88
|
+
{
|
|
89
|
+
href: `${p}/admin/automations`,
|
|
90
|
+
label: 'Automations',
|
|
91
|
+
// Time-based rules, next to the event-driven ones. The two are
|
|
92
|
+
// separate surfaces and always have been; this entry was simply
|
|
93
|
+
// never added, so the screen the backends render had no way in.
|
|
94
|
+
icon: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z',
|
|
95
|
+
position: 53.5,
|
|
96
|
+
},
|
|
88
97
|
{
|
|
89
98
|
href: `${p}/admin/webhooks`,
|
|
90
99
|
label: 'Webhooks',
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import EscalatedLayout from '../../../components/EscalatedLayout.vue';
|
|
3
|
+
import { Link, useForm } from '@inertiajs/vue3';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create and edit a time-based automation.
|
|
7
|
+
*
|
|
8
|
+
* The vocabulary here is the runner's: every field, operator and action type
|
|
9
|
+
* below is one the backend actually evaluates. Offering anything else would
|
|
10
|
+
* produce a rule that saves cleanly and then silently never matches.
|
|
11
|
+
*/
|
|
12
|
+
const props = defineProps({
|
|
13
|
+
automation: { type: Object, default: null },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** Fields that count hours take an operator and a number; the rest take a value. */
|
|
17
|
+
const TIME_FIELDS = ['hours_since_created', 'hours_since_updated', 'hours_since_assigned'];
|
|
18
|
+
|
|
19
|
+
const FIELDS = [
|
|
20
|
+
{ value: 'hours_since_created', label: 'Hours since created' },
|
|
21
|
+
{ value: 'hours_since_updated', label: 'Hours since last update' },
|
|
22
|
+
{ value: 'hours_since_assigned', label: 'Hours since assigned' },
|
|
23
|
+
{ value: 'status', label: 'Status' },
|
|
24
|
+
{ value: 'priority', label: 'Priority' },
|
|
25
|
+
{ value: 'assigned', label: 'Assignment' },
|
|
26
|
+
{ value: 'ticket_type', label: 'Ticket type' },
|
|
27
|
+
{ value: 'subject_contains', label: 'Subject contains' },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const OPERATORS = [
|
|
31
|
+
{ value: '>', label: 'more than' },
|
|
32
|
+
{ value: '>=', label: 'at least' },
|
|
33
|
+
{ value: '<', label: 'fewer than' },
|
|
34
|
+
{ value: '<=', label: 'at most' },
|
|
35
|
+
{ value: '=', label: 'exactly' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const ACTIONS = [
|
|
39
|
+
{ value: 'change_status', label: 'Change status' },
|
|
40
|
+
{ value: 'change_priority', label: 'Change priority' },
|
|
41
|
+
{ value: 'assign', label: 'Assign to agent' },
|
|
42
|
+
{ value: 'add_tag', label: 'Add tag' },
|
|
43
|
+
{ value: 'set_ticket_type', label: 'Set ticket type' },
|
|
44
|
+
{ value: 'add_note', label: 'Add internal note' },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const ASSIGNMENT_VALUES = [
|
|
48
|
+
{ value: 'assigned', label: 'Assigned' },
|
|
49
|
+
{ value: 'unassigned', label: 'Unassigned' },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const form = useForm({
|
|
53
|
+
name: props.automation?.name ?? '',
|
|
54
|
+
conditions: (props.automation?.conditions ?? []).map((c) => ({
|
|
55
|
+
field: c.field ?? 'hours_since_updated',
|
|
56
|
+
operator: c.operator ?? '>',
|
|
57
|
+
value: c.value ?? '',
|
|
58
|
+
})),
|
|
59
|
+
actions: (props.automation?.actions ?? []).map((a) => ({
|
|
60
|
+
type: a.type ?? 'change_status',
|
|
61
|
+
value: a.value ?? '',
|
|
62
|
+
})),
|
|
63
|
+
active: props.automation?.active ?? true,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// A new automation starts with one of each, because an empty one cannot be
|
|
67
|
+
// saved: the backend requires at least one condition and one action.
|
|
68
|
+
if (!form.conditions.length) {
|
|
69
|
+
form.conditions.push({ field: 'hours_since_updated', operator: '>', value: 24 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!form.actions.length) {
|
|
73
|
+
form.actions.push({ type: 'change_status', value: '' });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isTimeField(field) {
|
|
77
|
+
return TIME_FIELDS.includes(field);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function addCondition() {
|
|
81
|
+
form.conditions.push({ field: 'hours_since_updated', operator: '>', value: 24 });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function removeCondition(index) {
|
|
85
|
+
form.conditions.splice(index, 1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function addAction() {
|
|
89
|
+
form.actions.push({ type: 'change_status', value: '' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function removeAction(index) {
|
|
93
|
+
form.actions.splice(index, 1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function submit() {
|
|
97
|
+
if (props.automation?.id) {
|
|
98
|
+
form.put(route('escalated.admin.automations.update', props.automation.id));
|
|
99
|
+
} else {
|
|
100
|
+
form.post(route('escalated.admin.automations.store'));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<template>
|
|
106
|
+
<EscalatedLayout :title="automation ? 'Edit Automation' : 'Create Automation'">
|
|
107
|
+
<div class="mb-4">
|
|
108
|
+
<Link
|
|
109
|
+
:href="route('escalated.admin.automations.index')"
|
|
110
|
+
class="text-xs text-[var(--esc-panel-text-muted)] transition-colors hover:text-[var(--esc-panel-accent)]"
|
|
111
|
+
>
|
|
112
|
+
← Back to automations
|
|
113
|
+
</Link>
|
|
114
|
+
<h2 class="mt-2 text-lg font-semibold text-[var(--esc-panel-text-secondary)]">
|
|
115
|
+
{{ automation ? 'Edit Automation' : 'Create Automation' }}
|
|
116
|
+
</h2>
|
|
117
|
+
<p class="text-xs text-[var(--esc-panel-text-muted)]">
|
|
118
|
+
Runs on a schedule against open tickets. Every condition must match for the actions to apply.
|
|
119
|
+
</p>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<form class="space-y-6" @submit.prevent="submit">
|
|
123
|
+
<div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-4">
|
|
124
|
+
<label class="block text-xs font-medium text-[var(--esc-panel-text-muted)]" for="automation-name">
|
|
125
|
+
Name
|
|
126
|
+
</label>
|
|
127
|
+
<input
|
|
128
|
+
id="automation-name"
|
|
129
|
+
v-model="form.name"
|
|
130
|
+
type="text"
|
|
131
|
+
required
|
|
132
|
+
maxlength="255"
|
|
133
|
+
placeholder="Escalate tickets untouched for a day"
|
|
134
|
+
class="mt-1 w-full rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-accent)] focus:outline-none"
|
|
135
|
+
/>
|
|
136
|
+
<p v-if="form.errors.name" class="mt-1 text-xs text-red-400">{{ form.errors.name }}</p>
|
|
137
|
+
|
|
138
|
+
<label class="mt-4 flex items-center gap-2 text-sm text-[var(--esc-panel-text-secondary)]">
|
|
139
|
+
<input v-model="form.active" type="checkbox" class="rounded border-[var(--esc-panel-border)]" />
|
|
140
|
+
Active
|
|
141
|
+
</label>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-4">
|
|
145
|
+
<div class="mb-3 flex items-center justify-between">
|
|
146
|
+
<div>
|
|
147
|
+
<h3 class="text-sm font-semibold text-[var(--esc-panel-text-secondary)]">When</h3>
|
|
148
|
+
<p class="text-xs text-[var(--esc-panel-text-muted)]">All conditions must match</p>
|
|
149
|
+
</div>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
class="rounded-lg border border-[var(--esc-panel-border)] px-3 py-1.5 text-xs font-medium text-[var(--esc-panel-text-secondary)] transition-colors hover:bg-[var(--esc-panel-hover)]"
|
|
153
|
+
@click="addCondition"
|
|
154
|
+
>
|
|
155
|
+
Add condition
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div v-for="(condition, i) in form.conditions" :key="`condition-${i}`" class="mb-2 flex gap-2">
|
|
160
|
+
<select
|
|
161
|
+
v-model="condition.field"
|
|
162
|
+
:aria-label="`Condition ${i + 1} field`"
|
|
163
|
+
class="rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)]"
|
|
164
|
+
>
|
|
165
|
+
<option v-for="field in FIELDS" :key="field.value" :value="field.value">
|
|
166
|
+
{{ field.label }}
|
|
167
|
+
</option>
|
|
168
|
+
</select>
|
|
169
|
+
|
|
170
|
+
<select
|
|
171
|
+
v-if="isTimeField(condition.field)"
|
|
172
|
+
v-model="condition.operator"
|
|
173
|
+
:aria-label="`Condition ${i + 1} operator`"
|
|
174
|
+
class="rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)]"
|
|
175
|
+
>
|
|
176
|
+
<option v-for="operator in OPERATORS" :key="operator.value" :value="operator.value">
|
|
177
|
+
{{ operator.label }}
|
|
178
|
+
</option>
|
|
179
|
+
</select>
|
|
180
|
+
|
|
181
|
+
<select
|
|
182
|
+
v-if="condition.field === 'assigned'"
|
|
183
|
+
v-model="condition.value"
|
|
184
|
+
:aria-label="`Condition ${i + 1} value`"
|
|
185
|
+
class="flex-1 rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)]"
|
|
186
|
+
>
|
|
187
|
+
<option v-for="option in ASSIGNMENT_VALUES" :key="option.value" :value="option.value">
|
|
188
|
+
{{ option.label }}
|
|
189
|
+
</option>
|
|
190
|
+
</select>
|
|
191
|
+
<input
|
|
192
|
+
v-else
|
|
193
|
+
v-model="condition.value"
|
|
194
|
+
:type="isTimeField(condition.field) ? 'number' : 'text'"
|
|
195
|
+
:min="isTimeField(condition.field) ? 0 : undefined"
|
|
196
|
+
:aria-label="`Condition ${i + 1} value`"
|
|
197
|
+
:placeholder="isTimeField(condition.field) ? 'hours' : 'value'"
|
|
198
|
+
class="flex-1 rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)]"
|
|
199
|
+
/>
|
|
200
|
+
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
:aria-label="`Remove condition ${i + 1}`"
|
|
204
|
+
class="rounded-lg px-2 text-xs text-[var(--esc-panel-text-muted)] transition-colors hover:text-red-400"
|
|
205
|
+
:disabled="form.conditions.length === 1"
|
|
206
|
+
@click="removeCondition(i)"
|
|
207
|
+
>
|
|
208
|
+
Remove
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<p v-if="form.errors.conditions" class="mt-1 text-xs text-red-400">{{ form.errors.conditions }}</p>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-4">
|
|
216
|
+
<div class="mb-3 flex items-center justify-between">
|
|
217
|
+
<div>
|
|
218
|
+
<h3 class="text-sm font-semibold text-[var(--esc-panel-text-secondary)]">Then</h3>
|
|
219
|
+
<p class="text-xs text-[var(--esc-panel-text-muted)]">Applied to every matching ticket</p>
|
|
220
|
+
</div>
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
class="rounded-lg border border-[var(--esc-panel-border)] px-3 py-1.5 text-xs font-medium text-[var(--esc-panel-text-secondary)] transition-colors hover:bg-[var(--esc-panel-hover)]"
|
|
224
|
+
@click="addAction"
|
|
225
|
+
>
|
|
226
|
+
Add action
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div v-for="(action, i) in form.actions" :key="`action-${i}`" class="mb-2 flex gap-2">
|
|
231
|
+
<select
|
|
232
|
+
v-model="action.type"
|
|
233
|
+
:aria-label="`Action ${i + 1} type`"
|
|
234
|
+
class="rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)]"
|
|
235
|
+
>
|
|
236
|
+
<option v-for="option in ACTIONS" :key="option.value" :value="option.value">
|
|
237
|
+
{{ option.label }}
|
|
238
|
+
</option>
|
|
239
|
+
</select>
|
|
240
|
+
|
|
241
|
+
<input
|
|
242
|
+
v-model="action.value"
|
|
243
|
+
type="text"
|
|
244
|
+
:aria-label="`Action ${i + 1} value`"
|
|
245
|
+
placeholder="value"
|
|
246
|
+
class="flex-1 rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)]"
|
|
247
|
+
/>
|
|
248
|
+
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
:aria-label="`Remove action ${i + 1}`"
|
|
252
|
+
class="rounded-lg px-2 text-xs text-[var(--esc-panel-text-muted)] transition-colors hover:text-red-400"
|
|
253
|
+
:disabled="form.actions.length === 1"
|
|
254
|
+
@click="removeAction(i)"
|
|
255
|
+
>
|
|
256
|
+
Remove
|
|
257
|
+
</button>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<p v-if="form.errors.actions" class="mt-1 text-xs text-red-400">{{ form.errors.actions }}</p>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<div class="flex items-center gap-3">
|
|
264
|
+
<button
|
|
265
|
+
type="submit"
|
|
266
|
+
:disabled="form.processing"
|
|
267
|
+
class="rounded-lg bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] px-4 py-2 text-sm font-medium text-white transition-all hover:from-[var(--esc-panel-accent-hover)] hover:to-[var(--esc-panel-accent-secondary-hover)] disabled:opacity-50"
|
|
268
|
+
>
|
|
269
|
+
{{ automation ? 'Save changes' : 'Create automation' }}
|
|
270
|
+
</button>
|
|
271
|
+
<Link
|
|
272
|
+
:href="route('escalated.admin.automations.index')"
|
|
273
|
+
class="text-sm text-[var(--esc-panel-text-muted)] transition-colors hover:text-[var(--esc-panel-text-secondary)]"
|
|
274
|
+
>
|
|
275
|
+
Cancel
|
|
276
|
+
</Link>
|
|
277
|
+
</div>
|
|
278
|
+
</form>
|
|
279
|
+
</EscalatedLayout>
|
|
280
|
+
</template>
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import EscalatedLayout from '../../../components/EscalatedLayout.vue';
|
|
3
|
+
import { Link, router } from '@inertiajs/vue3';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Automations are the time-based half of the admin automation surface: a cron
|
|
7
|
+
* scan matches open tickets on how long they have been sitting and applies
|
|
8
|
+
* actions. Workflows are the event-driven half, and Macros are the agent's
|
|
9
|
+
* manual one. The three co-exist; none subsumes the others.
|
|
10
|
+
*/
|
|
11
|
+
defineProps({
|
|
12
|
+
automations: { type: Array, default: () => [] },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const FIELD_LABELS = {
|
|
16
|
+
hours_since_created: 'Hours since created',
|
|
17
|
+
hours_since_updated: 'Hours since updated',
|
|
18
|
+
hours_since_assigned: 'Hours since assigned',
|
|
19
|
+
status: 'Status',
|
|
20
|
+
priority: 'Priority',
|
|
21
|
+
assigned: 'Assignment',
|
|
22
|
+
ticket_type: 'Type',
|
|
23
|
+
subject_contains: 'Subject contains',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const ACTION_LABELS = {
|
|
27
|
+
change_status: 'Change status',
|
|
28
|
+
assign: 'Assign to',
|
|
29
|
+
add_tag: 'Add tag',
|
|
30
|
+
change_priority: 'Change priority',
|
|
31
|
+
add_note: 'Add internal note',
|
|
32
|
+
set_ticket_type: 'Set type',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function destroy(id) {
|
|
36
|
+
if (confirm('Delete this automation? This cannot be undone.')) {
|
|
37
|
+
router.delete(route('escalated.admin.automations.destroy', id));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toggleActive(automation) {
|
|
42
|
+
router.put(route('escalated.admin.automations.update', automation.id), {
|
|
43
|
+
name: automation.name,
|
|
44
|
+
conditions: automation.conditions ?? [],
|
|
45
|
+
actions: automation.actions ?? [],
|
|
46
|
+
active: !automation.active,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A condition in the words an admin would use, not the words it is stored in. */
|
|
51
|
+
function conditionSummary(condition) {
|
|
52
|
+
const label = FIELD_LABELS[condition?.field] ?? condition?.field ?? '';
|
|
53
|
+
|
|
54
|
+
if (!label) {
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (String(condition.field ?? '').startsWith('hours_since_')) {
|
|
59
|
+
return `${label} ${condition.operator ?? '>'} ${condition.value}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return `${label} is ${condition.value}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function actionSummary(action) {
|
|
66
|
+
const label = ACTION_LABELS[action?.type] ?? action?.type ?? '';
|
|
67
|
+
|
|
68
|
+
if (!label) {
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return action.value === undefined || action.value === null || action.value === ''
|
|
73
|
+
? label
|
|
74
|
+
: `${label}: ${action.value}`;
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<template>
|
|
79
|
+
<EscalatedLayout title="Automations">
|
|
80
|
+
<div class="mb-4 flex items-center justify-between">
|
|
81
|
+
<div>
|
|
82
|
+
<h2 class="text-lg font-semibold text-[var(--esc-panel-text-secondary)]">Automations</h2>
|
|
83
|
+
<p class="text-xs text-[var(--esc-panel-text-muted)]">
|
|
84
|
+
Time-based rules. A scheduled scan matches open tickets on how long they have been waiting and
|
|
85
|
+
applies actions — for events as they happen, use Workflows.
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
<Link
|
|
89
|
+
:href="route('escalated.admin.automations.create')"
|
|
90
|
+
class="rounded-lg bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] px-4 py-2 text-sm font-medium text-white shadow-lg shadow-[var(--esc-panel-bg)]/20 transition-all hover:from-[var(--esc-panel-accent-hover)] hover:to-[var(--esc-panel-accent-secondary-hover)]"
|
|
91
|
+
>
|
|
92
|
+
Create Automation
|
|
93
|
+
</Link>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div class="overflow-hidden rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)]">
|
|
97
|
+
<table class="min-w-full divide-y divide-[var(--esc-panel-border)]">
|
|
98
|
+
<thead>
|
|
99
|
+
<tr class="bg-[var(--esc-panel-hover)]">
|
|
100
|
+
<th
|
|
101
|
+
class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
|
|
102
|
+
>
|
|
103
|
+
Name
|
|
104
|
+
</th>
|
|
105
|
+
<th
|
|
106
|
+
class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
|
|
107
|
+
>
|
|
108
|
+
When
|
|
109
|
+
</th>
|
|
110
|
+
<th
|
|
111
|
+
class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
|
|
112
|
+
>
|
|
113
|
+
Then
|
|
114
|
+
</th>
|
|
115
|
+
<th
|
|
116
|
+
class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
|
|
117
|
+
>
|
|
118
|
+
Status
|
|
119
|
+
</th>
|
|
120
|
+
<th
|
|
121
|
+
class="px-4 py-3 text-right text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
|
|
122
|
+
>
|
|
123
|
+
Actions
|
|
124
|
+
</th>
|
|
125
|
+
</tr>
|
|
126
|
+
</thead>
|
|
127
|
+
<tbody class="divide-y divide-[var(--esc-panel-border)]">
|
|
128
|
+
<tr v-if="!automations?.length">
|
|
129
|
+
<td colspan="5" class="px-4 py-12 text-center">
|
|
130
|
+
<svg
|
|
131
|
+
class="mx-auto mb-3 h-8 w-8 text-[var(--esc-panel-text-muted)]"
|
|
132
|
+
fill="none"
|
|
133
|
+
stroke="currentColor"
|
|
134
|
+
stroke-width="1.5"
|
|
135
|
+
viewBox="0 0 24 24"
|
|
136
|
+
>
|
|
137
|
+
<path
|
|
138
|
+
stroke-linecap="round"
|
|
139
|
+
stroke-linejoin="round"
|
|
140
|
+
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
141
|
+
/>
|
|
142
|
+
</svg>
|
|
143
|
+
<p class="text-sm text-[var(--esc-panel-text-muted)]">No automations yet</p>
|
|
144
|
+
<p class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
|
|
145
|
+
Chase tickets that have gone quiet — escalate after 24 hours, close after a week
|
|
146
|
+
</p>
|
|
147
|
+
</td>
|
|
148
|
+
</tr>
|
|
149
|
+
<tr
|
|
150
|
+
v-for="automation in automations"
|
|
151
|
+
:key="automation.id"
|
|
152
|
+
class="transition-colors hover:bg-[var(--esc-panel-hover)]"
|
|
153
|
+
>
|
|
154
|
+
<td class="px-4 py-3">
|
|
155
|
+
<Link
|
|
156
|
+
:href="route('escalated.admin.automations.edit', automation.id)"
|
|
157
|
+
class="text-sm font-medium text-[var(--esc-panel-text-secondary)] hover:text-[var(--esc-panel-accent)]"
|
|
158
|
+
>
|
|
159
|
+
{{ automation.name }}
|
|
160
|
+
</Link>
|
|
161
|
+
</td>
|
|
162
|
+
<td class="px-4 py-3">
|
|
163
|
+
<div class="flex flex-wrap gap-1">
|
|
164
|
+
<span
|
|
165
|
+
v-for="(condition, i) in automation.conditions ?? []"
|
|
166
|
+
:key="i"
|
|
167
|
+
class="rounded bg-[var(--esc-panel-hover)] px-2 py-0.5 text-[11px] text-[var(--esc-panel-text-muted)]"
|
|
168
|
+
>
|
|
169
|
+
{{ conditionSummary(condition) }}
|
|
170
|
+
</span>
|
|
171
|
+
<span
|
|
172
|
+
v-if="!(automation.conditions ?? []).length"
|
|
173
|
+
class="text-[11px] text-[var(--esc-panel-text-muted)]"
|
|
174
|
+
>
|
|
175
|
+
—
|
|
176
|
+
</span>
|
|
177
|
+
</div>
|
|
178
|
+
</td>
|
|
179
|
+
<td class="px-4 py-3">
|
|
180
|
+
<div class="flex flex-wrap gap-1">
|
|
181
|
+
<span
|
|
182
|
+
v-for="(action, i) in automation.actions ?? []"
|
|
183
|
+
:key="i"
|
|
184
|
+
class="rounded bg-[var(--esc-panel-accent)]/10 px-2 py-0.5 text-[11px] text-[var(--esc-panel-accent)]"
|
|
185
|
+
>
|
|
186
|
+
{{ actionSummary(action) }}
|
|
187
|
+
</span>
|
|
188
|
+
<span
|
|
189
|
+
v-if="!(automation.actions ?? []).length"
|
|
190
|
+
class="text-[11px] text-[var(--esc-panel-text-muted)]"
|
|
191
|
+
>
|
|
192
|
+
—
|
|
193
|
+
</span>
|
|
194
|
+
</div>
|
|
195
|
+
</td>
|
|
196
|
+
<td class="px-4 py-3">
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
class="rounded px-2 py-0.5 text-[11px] font-medium transition-colors"
|
|
200
|
+
:class="
|
|
201
|
+
automation.active
|
|
202
|
+
? 'bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
|
|
203
|
+
: 'bg-gray-500/10 text-gray-400 hover:bg-gray-500/20'
|
|
204
|
+
"
|
|
205
|
+
@click="toggleActive(automation)"
|
|
206
|
+
>
|
|
207
|
+
{{ automation.active ? 'Active' : 'Paused' }}
|
|
208
|
+
</button>
|
|
209
|
+
</td>
|
|
210
|
+
<td class="px-4 py-3 text-right">
|
|
211
|
+
<Link
|
|
212
|
+
:href="route('escalated.admin.automations.edit', automation.id)"
|
|
213
|
+
class="text-xs font-medium text-[var(--esc-panel-text-muted)] transition-colors hover:text-[var(--esc-panel-accent)]"
|
|
214
|
+
>
|
|
215
|
+
Edit
|
|
216
|
+
</Link>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
class="ml-3 text-xs font-medium text-[var(--esc-panel-text-muted)] transition-colors hover:text-red-400"
|
|
220
|
+
@click="destroy(automation.id)"
|
|
221
|
+
>
|
|
222
|
+
Delete
|
|
223
|
+
</button>
|
|
224
|
+
</td>
|
|
225
|
+
</tr>
|
|
226
|
+
</tbody>
|
|
227
|
+
</table>
|
|
228
|
+
</div>
|
|
229
|
+
</EscalatedLayout>
|
|
230
|
+
</template>
|
|
@@ -5,6 +5,10 @@ import { ref, computed } from 'vue';
|
|
|
5
5
|
|
|
6
6
|
const props = defineProps({
|
|
7
7
|
plugins: { type: Array, default: () => [] },
|
|
8
|
+
// Backends that load plugins from uploaded ZIPs (e.g. Laravel) support the
|
|
9
|
+
// upload flow; backends where plugins are compiled host modules registered
|
|
10
|
+
// in config (e.g. Phoenix) pass can_upload: false to hide it.
|
|
11
|
+
can_upload: { type: Boolean, default: true },
|
|
8
12
|
});
|
|
9
13
|
|
|
10
14
|
const confirmingDelete = ref(null);
|
|
@@ -93,7 +97,7 @@ function statusLabel(plugin) {
|
|
|
93
97
|
{{ activePlugins.length }} active, {{ inactivePlugins.length }} inactive
|
|
94
98
|
</p>
|
|
95
99
|
</div>
|
|
96
|
-
<div>
|
|
100
|
+
<div v-if="can_upload">
|
|
97
101
|
<input ref="fileInput" type="file" accept=".zip" class="hidden" @change="handleUpload" />
|
|
98
102
|
<button
|
|
99
103
|
:disabled="uploading"
|
|
@@ -144,9 +148,12 @@ function statusLabel(plugin) {
|
|
|
144
148
|
/>
|
|
145
149
|
</svg>
|
|
146
150
|
<h3 class="text-sm font-medium text-[var(--esc-panel-text-secondary)]">No plugins installed</h3>
|
|
147
|
-
<p class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
|
|
151
|
+
<p v-if="can_upload" class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
|
|
148
152
|
Upload a plugin ZIP to extend Escalated with custom functionality.
|
|
149
153
|
</p>
|
|
154
|
+
<p v-else class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
|
|
155
|
+
Register a plugin in the host application to extend Escalated with custom functionality.
|
|
156
|
+
</p>
|
|
150
157
|
</div>
|
|
151
158
|
|
|
152
159
|
<!-- Plugin cards -->
|
|
@@ -234,6 +241,7 @@ function statusLabel(plugin) {
|
|
|
234
241
|
</svg>
|
|
235
242
|
Installed {{ plugin.installed_at }}
|
|
236
243
|
</span>
|
|
244
|
+
<span v-if="plugin.module" class="font-mono">{{ plugin.module }}</span>
|
|
237
245
|
</div>
|
|
238
246
|
|
|
239
247
|
<!-- Provided extensions summary -->
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import EscalatedLayout from '../../../components/EscalatedLayout.vue';
|
|
3
|
+
import { useForm, router } from '@inertiajs/vue3';
|
|
4
|
+
import { computed, ref } from 'vue';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which database Escalated's own tables live on.
|
|
8
|
+
*
|
|
9
|
+
* Two things about this screen are deliberate and worth not undoing.
|
|
10
|
+
*
|
|
11
|
+
* It refuses a switch to a database with no Escalated tables. That case does
|
|
12
|
+
* not error at runtime — the panel simply comes up with no tickets, no
|
|
13
|
+
* departments and no settings, which reads exactly like data loss. The server
|
|
14
|
+
* refuses it, and the option is disabled here so the mistake is hard to make
|
|
15
|
+
* in the first place.
|
|
16
|
+
*
|
|
17
|
+
* And when `escalated.connection` is set in config or .env, the whole form is
|
|
18
|
+
* read-only. That value is deployed infrastructure; a web form must not be
|
|
19
|
+
* able to quietly disagree with it.
|
|
20
|
+
*/
|
|
21
|
+
const props = defineProps({
|
|
22
|
+
connections: { type: Array, default: () => [] },
|
|
23
|
+
current: { type: String, default: null },
|
|
24
|
+
hostDefault: { type: String, default: null },
|
|
25
|
+
pinnedByConfig: { type: Boolean, default: false },
|
|
26
|
+
tablePrefix: { type: String, default: 'escalated_' },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const form = useForm({
|
|
30
|
+
connection: props.current ?? '',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const testing = ref(null);
|
|
34
|
+
|
|
35
|
+
const active = computed(() => props.connections.find((c) => c.is_current) ?? null);
|
|
36
|
+
|
|
37
|
+
const selected = computed(
|
|
38
|
+
() => props.connections.find((c) => c.name === (form.connection || props.hostDefault)) ?? null,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const isChanging = computed(() => (form.connection || null) !== (props.current || null));
|
|
42
|
+
|
|
43
|
+
function submit() {
|
|
44
|
+
if (props.pinnedByConfig) return;
|
|
45
|
+
form.post(route('escalated.admin.settings.database.update'), { preserveScroll: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function test(name) {
|
|
49
|
+
testing.value = name;
|
|
50
|
+
|
|
51
|
+
router.post(
|
|
52
|
+
route('escalated.admin.settings.database.test'),
|
|
53
|
+
{ connection: name },
|
|
54
|
+
{
|
|
55
|
+
preserveScroll: true,
|
|
56
|
+
preserveState: false,
|
|
57
|
+
onFinish: () => {
|
|
58
|
+
testing.value = null;
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function statusLabel(connection) {
|
|
65
|
+
if (!connection.reachable) return 'Unreachable';
|
|
66
|
+
if (!connection.migrated) return 'Not migrated';
|
|
67
|
+
return 'Ready';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function statusClass(connection) {
|
|
71
|
+
if (!connection.reachable) return 'text-[var(--esc-danger)]';
|
|
72
|
+
if (!connection.migrated) return 'text-[var(--esc-warning)]';
|
|
73
|
+
return 'text-[var(--esc-success)]';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatCount(value) {
|
|
77
|
+
return value === null || value === undefined ? null : value.toLocaleString();
|
|
78
|
+
}
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<template>
|
|
82
|
+
<EscalatedLayout title="Database Connection">
|
|
83
|
+
<div class="mx-auto max-w-3xl space-y-6">
|
|
84
|
+
<div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6">
|
|
85
|
+
<h3 class="mb-1 text-sm font-semibold text-[var(--esc-panel-text)]">Current connection</h3>
|
|
86
|
+
<p class="mb-5 text-xs text-[var(--esc-panel-text-muted)]">
|
|
87
|
+
Where Escalated's own tables (<code>{{ tablePrefix }}*</code>) are read and written. Your users
|
|
88
|
+
table is not affected — it stays wherever your application keeps it.
|
|
89
|
+
</p>
|
|
90
|
+
|
|
91
|
+
<dl v-if="active" class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
|
92
|
+
<dt class="text-[var(--esc-panel-text-muted)]">Connection</dt>
|
|
93
|
+
<dd class="font-medium text-[var(--esc-panel-text)]">
|
|
94
|
+
{{ active.name }}
|
|
95
|
+
<span v-if="active.is_host_default" class="text-xs text-[var(--esc-panel-text-muted)]">
|
|
96
|
+
(application default)
|
|
97
|
+
</span>
|
|
98
|
+
</dd>
|
|
99
|
+
|
|
100
|
+
<dt class="text-[var(--esc-panel-text-muted)]">Driver</dt>
|
|
101
|
+
<dd class="text-[var(--esc-panel-text-secondary)]">{{ active.driver ?? '—' }}</dd>
|
|
102
|
+
|
|
103
|
+
<dt class="text-[var(--esc-panel-text-muted)]">Database</dt>
|
|
104
|
+
<dd class="break-all text-[var(--esc-panel-text-secondary)]">{{ active.database ?? '—' }}</dd>
|
|
105
|
+
|
|
106
|
+
<dt class="text-[var(--esc-panel-text-muted)]">Tickets stored</dt>
|
|
107
|
+
<dd class="text-[var(--esc-panel-text-secondary)]">
|
|
108
|
+
{{ formatCount(active.ticket_count) ?? '—' }}
|
|
109
|
+
</dd>
|
|
110
|
+
</dl>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div
|
|
114
|
+
v-if="pinnedByConfig"
|
|
115
|
+
class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface-alt)] p-4 text-sm text-[var(--esc-panel-text-secondary)]"
|
|
116
|
+
>
|
|
117
|
+
This connection is set in your application config (<code>escalated.connection</code> /
|
|
118
|
+
<code>ESCALATED_DB_CONNECTION</code>) and cannot be changed from here. Edit it where it is deployed, so
|
|
119
|
+
the panel and your configuration cannot disagree.
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<form v-else class="space-y-6" @submit.prevent="submit">
|
|
123
|
+
<div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6">
|
|
124
|
+
<h3 class="mb-1 text-sm font-semibold text-[var(--esc-panel-text)]">Change connection</h3>
|
|
125
|
+
<p class="mb-5 text-xs text-[var(--esc-panel-text-muted)]">
|
|
126
|
+
Only connections already carrying Escalated's tables can be selected. Run the package migrations
|
|
127
|
+
against a database before pointing Escalated at it.
|
|
128
|
+
</p>
|
|
129
|
+
|
|
130
|
+
<div class="space-y-2">
|
|
131
|
+
<label
|
|
132
|
+
v-for="connection in connections"
|
|
133
|
+
:key="connection.name"
|
|
134
|
+
:class="[
|
|
135
|
+
'flex items-start gap-3 rounded-lg border p-3 transition',
|
|
136
|
+
connection.migrated
|
|
137
|
+
? 'cursor-pointer border-[var(--esc-panel-border)] hover:bg-[var(--esc-panel-surface-alt)]'
|
|
138
|
+
: 'cursor-not-allowed border-[var(--esc-panel-border)] opacity-60',
|
|
139
|
+
]"
|
|
140
|
+
>
|
|
141
|
+
<input
|
|
142
|
+
v-model="form.connection"
|
|
143
|
+
type="radio"
|
|
144
|
+
:value="connection.is_host_default ? '' : connection.name"
|
|
145
|
+
:disabled="!connection.migrated"
|
|
146
|
+
class="mt-1"
|
|
147
|
+
/>
|
|
148
|
+
<span class="min-w-0 flex-1">
|
|
149
|
+
<span class="flex flex-wrap items-center gap-2">
|
|
150
|
+
<span class="text-sm font-medium text-[var(--esc-panel-text)]">
|
|
151
|
+
{{ connection.name }}
|
|
152
|
+
</span>
|
|
153
|
+
<span
|
|
154
|
+
v-if="connection.is_host_default"
|
|
155
|
+
class="text-xs text-[var(--esc-panel-text-muted)]"
|
|
156
|
+
>
|
|
157
|
+
application default
|
|
158
|
+
</span>
|
|
159
|
+
<span :class="['text-xs font-medium', statusClass(connection)]">
|
|
160
|
+
{{ statusLabel(connection) }}
|
|
161
|
+
</span>
|
|
162
|
+
</span>
|
|
163
|
+
<span class="mt-0.5 block text-xs text-[var(--esc-panel-text-muted)]">
|
|
164
|
+
{{ connection.driver ?? 'unknown driver' }} ·
|
|
165
|
+
{{ connection.database ?? 'no database configured' }}
|
|
166
|
+
<template v-if="connection.ticket_count !== null">
|
|
167
|
+
· {{ formatCount(connection.ticket_count) }} tickets
|
|
168
|
+
</template>
|
|
169
|
+
</span>
|
|
170
|
+
<span
|
|
171
|
+
v-if="connection.error"
|
|
172
|
+
class="mt-1 block break-all text-xs text-[var(--esc-danger)]"
|
|
173
|
+
>
|
|
174
|
+
{{ connection.error }}
|
|
175
|
+
</span>
|
|
176
|
+
<span
|
|
177
|
+
v-else-if="!connection.migrated"
|
|
178
|
+
class="mt-1 block text-xs text-[var(--esc-panel-text-muted)]"
|
|
179
|
+
>
|
|
180
|
+
No Escalated tables here yet. Switching would show an empty panel, so it is
|
|
181
|
+
disabled.
|
|
182
|
+
</span>
|
|
183
|
+
</span>
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
class="shrink-0 rounded-md border border-[var(--esc-panel-border-input)] px-2 py-1 text-xs text-[var(--esc-panel-text-secondary)]"
|
|
187
|
+
:disabled="testing === connection.name"
|
|
188
|
+
@click.prevent="test(connection.name)"
|
|
189
|
+
>
|
|
190
|
+
{{ testing === connection.name ? 'Testing…' : 'Test' }}
|
|
191
|
+
</button>
|
|
192
|
+
</label>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<p v-if="form.errors.connection" class="mt-3 text-xs text-[var(--esc-danger)]">
|
|
196
|
+
{{ form.errors.connection }}
|
|
197
|
+
</p>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div
|
|
201
|
+
v-if="isChanging"
|
|
202
|
+
class="rounded-xl border border-[var(--esc-warning)] bg-[var(--esc-panel-surface-alt)] p-4 text-sm text-[var(--esc-panel-text-secondary)]"
|
|
203
|
+
>
|
|
204
|
+
<p class="font-medium text-[var(--esc-panel-text)]">This does not move any data.</p>
|
|
205
|
+
<p class="mt-1 text-xs">
|
|
206
|
+
Escalated will start reading and writing
|
|
207
|
+
<strong>{{ selected?.name ?? hostDefault }}</strong
|
|
208
|
+
>, which currently holds
|
|
209
|
+
<strong>{{ formatCount(selected?.ticket_count) ?? 'an unknown number of' }}</strong> tickets.
|
|
210
|
+
The <strong>{{ formatCount(active?.ticket_count) ?? 'existing' }}</strong> tickets on
|
|
211
|
+
<strong>{{ active?.name }}</strong> stay where they are and will no longer be visible in the
|
|
212
|
+
panel.
|
|
213
|
+
</p>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div class="flex justify-end">
|
|
217
|
+
<button
|
|
218
|
+
type="submit"
|
|
219
|
+
:disabled="form.processing || !isChanging"
|
|
220
|
+
class="rounded-lg bg-[var(--esc-accent)] px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
|
|
221
|
+
>
|
|
222
|
+
{{ form.processing ? 'Saving…' : 'Use this connection' }}
|
|
223
|
+
</button>
|
|
224
|
+
</div>
|
|
225
|
+
</form>
|
|
226
|
+
</div>
|
|
227
|
+
</EscalatedLayout>
|
|
228
|
+
</template>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import EscalatedLayout from '../../../components/EscalatedLayout.vue';
|
|
3
|
+
import { useForm, Link } from '@inertiajs/vue3';
|
|
4
|
+
import { computed } from 'vue';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What happens to a ticket raised by someone who is not signed in.
|
|
8
|
+
*
|
|
9
|
+
* The three modes are the guest policy: leave it unattached, attach it to a
|
|
10
|
+
* standing guest account, or ask the person to sign up before it is accepted.
|
|
11
|
+
* Each backend stores these as three settings, and the second and third each
|
|
12
|
+
* need one value alongside the mode.
|
|
13
|
+
*/
|
|
14
|
+
const props = defineProps({
|
|
15
|
+
settings: { type: Object, default: () => ({}) },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const MODES = [
|
|
19
|
+
{
|
|
20
|
+
value: 'unassigned',
|
|
21
|
+
label: 'Leave unattached',
|
|
22
|
+
description: 'The ticket is stored with the guest name and email and no user behind it.',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
value: 'guest_user',
|
|
26
|
+
label: 'Attach to a guest account',
|
|
27
|
+
description: 'Every guest ticket is owned by one standing account, so agents see a consistent requester.',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
value: 'prompt_signup',
|
|
31
|
+
label: 'Ask the guest to sign up',
|
|
32
|
+
description: 'The guest is sent to your sign-up page before the ticket is accepted.',
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const form = useForm({
|
|
37
|
+
guest_policy_mode: props.settings?.guest_policy_mode ?? 'unassigned',
|
|
38
|
+
guest_policy_user_id: props.settings?.guest_policy_user_id ?? '',
|
|
39
|
+
guest_policy_signup_url_template: props.settings?.guest_policy_signup_url_template ?? '',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const needsUserId = computed(() => form.guest_policy_mode === 'guest_user');
|
|
43
|
+
const needsSignupUrl = computed(() => form.guest_policy_mode === 'prompt_signup');
|
|
44
|
+
|
|
45
|
+
function submit() {
|
|
46
|
+
form.post(route('escalated.admin.settings.public-tickets.update'));
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<EscalatedLayout title="Public Tickets">
|
|
52
|
+
<div class="mx-auto max-w-2xl">
|
|
53
|
+
<div class="mb-6">
|
|
54
|
+
<Link
|
|
55
|
+
:href="route('escalated.admin.settings')"
|
|
56
|
+
class="text-sm text-[var(--esc-panel-text-muted)] hover:text-[var(--esc-panel-text-secondary)]"
|
|
57
|
+
>
|
|
58
|
+
← Back to Settings
|
|
59
|
+
</Link>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6">
|
|
63
|
+
<h2 class="text-lg font-semibold text-[var(--esc-panel-text-secondary)]">Public Tickets</h2>
|
|
64
|
+
<p class="mt-1 text-sm text-[var(--esc-panel-text-muted)]">
|
|
65
|
+
How a ticket raised by someone who is not signed in is recorded.
|
|
66
|
+
</p>
|
|
67
|
+
|
|
68
|
+
<form class="mt-6 space-y-6" @submit.prevent="submit">
|
|
69
|
+
<fieldset class="space-y-3">
|
|
70
|
+
<legend class="mb-1.5 text-sm font-medium text-[var(--esc-panel-text-secondary)]">
|
|
71
|
+
Guest policy
|
|
72
|
+
</legend>
|
|
73
|
+
|
|
74
|
+
<label
|
|
75
|
+
v-for="mode in MODES"
|
|
76
|
+
:key="mode.value"
|
|
77
|
+
class="flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors"
|
|
78
|
+
:class="
|
|
79
|
+
form.guest_policy_mode === mode.value
|
|
80
|
+
? 'border-[var(--esc-panel-accent)]/40 bg-[var(--esc-panel-hover)]'
|
|
81
|
+
: 'border-[var(--esc-panel-border)] hover:bg-[var(--esc-panel-hover)]'
|
|
82
|
+
"
|
|
83
|
+
>
|
|
84
|
+
<input
|
|
85
|
+
v-model="form.guest_policy_mode"
|
|
86
|
+
type="radio"
|
|
87
|
+
name="guest_policy_mode"
|
|
88
|
+
:value="mode.value"
|
|
89
|
+
class="mt-1"
|
|
90
|
+
/>
|
|
91
|
+
<span>
|
|
92
|
+
<span class="block text-sm text-[var(--esc-panel-text-secondary)]">
|
|
93
|
+
{{ mode.label }}
|
|
94
|
+
</span>
|
|
95
|
+
<span class="block text-xs text-[var(--esc-panel-text-muted)]">
|
|
96
|
+
{{ mode.description }}
|
|
97
|
+
</span>
|
|
98
|
+
</span>
|
|
99
|
+
</label>
|
|
100
|
+
|
|
101
|
+
<p v-if="form.errors.guest_policy_mode" class="text-xs text-red-400">
|
|
102
|
+
{{ form.errors.guest_policy_mode }}
|
|
103
|
+
</p>
|
|
104
|
+
</fieldset>
|
|
105
|
+
|
|
106
|
+
<div v-if="needsUserId">
|
|
107
|
+
<label
|
|
108
|
+
class="mb-1.5 block text-sm font-medium text-[var(--esc-panel-text-secondary)]"
|
|
109
|
+
for="guest-user-id"
|
|
110
|
+
>
|
|
111
|
+
Guest account user ID
|
|
112
|
+
</label>
|
|
113
|
+
<input
|
|
114
|
+
id="guest-user-id"
|
|
115
|
+
v-model="form.guest_policy_user_id"
|
|
116
|
+
type="number"
|
|
117
|
+
min="1"
|
|
118
|
+
required
|
|
119
|
+
class="w-full rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-hover)] px-3 py-2.5 text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-accent)]/40 focus:outline-none"
|
|
120
|
+
/>
|
|
121
|
+
<p class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
|
|
122
|
+
The id of an existing user in your application. Every guest ticket is attached to it.
|
|
123
|
+
</p>
|
|
124
|
+
<p v-if="form.errors.guest_policy_user_id" class="mt-1 text-xs text-red-400">
|
|
125
|
+
{{ form.errors.guest_policy_user_id }}
|
|
126
|
+
</p>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div v-if="needsSignupUrl">
|
|
130
|
+
<label
|
|
131
|
+
class="mb-1.5 block text-sm font-medium text-[var(--esc-panel-text-secondary)]"
|
|
132
|
+
for="signup-url"
|
|
133
|
+
>
|
|
134
|
+
Sign-up URL
|
|
135
|
+
</label>
|
|
136
|
+
<input
|
|
137
|
+
id="signup-url"
|
|
138
|
+
v-model="form.guest_policy_signup_url_template"
|
|
139
|
+
type="text"
|
|
140
|
+
maxlength="500"
|
|
141
|
+
placeholder="/register?email={email}"
|
|
142
|
+
class="w-full rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-hover)] px-3 py-2.5 text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-accent)]/40 focus:outline-none"
|
|
143
|
+
/>
|
|
144
|
+
<p class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
|
|
145
|
+
<code>{email}</code> is replaced with the address the guest entered.
|
|
146
|
+
</p>
|
|
147
|
+
<p v-if="form.errors.guest_policy_signup_url_template" class="mt-1 text-xs text-red-400">
|
|
148
|
+
{{ form.errors.guest_policy_signup_url_template }}
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<button
|
|
153
|
+
type="submit"
|
|
154
|
+
:disabled="form.processing"
|
|
155
|
+
class="rounded-lg bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] px-4 py-2 text-sm font-medium text-white transition-all hover:from-[var(--esc-panel-accent-hover)] hover:to-[var(--esc-panel-accent-secondary-hover)] disabled:opacity-50"
|
|
156
|
+
>
|
|
157
|
+
Save settings
|
|
158
|
+
</button>
|
|
159
|
+
</form>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</EscalatedLayout>
|
|
163
|
+
</template>
|
|
@@ -108,8 +108,7 @@ function togglePayload(id) {
|
|
|
108
108
|
<td colspan="5" class="bg-[var(--esc-panel-hover)] px-4 py-3">
|
|
109
109
|
<pre
|
|
110
110
|
class="max-h-40 overflow-auto rounded-lg bg-[var(--esc-panel-surface-alt)] p-3 text-xs text-[var(--esc-panel-text-tertiary)]"
|
|
111
|
-
>{{ JSON.stringify(delivery.payload, null, 2) }}</pre
|
|
112
|
-
>
|
|
111
|
+
>{{ JSON.stringify(delivery.payload, null, 2) }}</pre>
|
|
113
112
|
</td>
|
|
114
113
|
</tr>
|
|
115
114
|
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import Builder from './Builder.vue';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The workflow editor, under the name the backends render.
|
|
6
|
+
*
|
|
7
|
+
* Builder.vue is the editor itself and takes exactly these props. Several
|
|
8
|
+
* backends render `Escalated/Admin/Workflows/Form` for both create and edit,
|
|
9
|
+
* and a page name with no component behind it is not an error in Inertia --
|
|
10
|
+
* the panel simply comes up blank. This is that name.
|
|
11
|
+
*/
|
|
12
|
+
defineProps({
|
|
13
|
+
workflow: { type: Object, default: null },
|
|
14
|
+
|
|
15
|
+
// Passed by the backends alongside the workflow. Builder derives its own
|
|
16
|
+
// trigger list, so this is accepted rather than forwarded; declaring it
|
|
17
|
+
// keeps it out of the fallthrough attributes.
|
|
18
|
+
triggerEvents: { type: [Array, Object], default: null },
|
|
19
|
+
});
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<Builder :workflow="workflow" />
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import EscalatedLayout from '../../components/EscalatedLayout.vue';
|
|
3
|
+
import { computed, resolveComponent } from 'vue';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A full page registered by a plugin.
|
|
7
|
+
*
|
|
8
|
+
* A plugin declares a route on the backend and a component on the frontend;
|
|
9
|
+
* the backend renders this page with the component's name, and this is where
|
|
10
|
+
* the two meet. `defineEscalatedPlugin` registers those components globally on
|
|
11
|
+
* the app, so the name is resolved from the global registry rather than
|
|
12
|
+
* imported.
|
|
13
|
+
*
|
|
14
|
+
* Two backends render `Escalated/Plugin/Page`, and there was no component
|
|
15
|
+
* behind the name — so every plugin page was blank, whether or not the plugin
|
|
16
|
+
* had registered its component correctly. A plugin that has not been installed
|
|
17
|
+
* on the frontend now says so instead.
|
|
18
|
+
*/
|
|
19
|
+
const props = defineProps({
|
|
20
|
+
plugin: { type: String, default: '' },
|
|
21
|
+
component: { type: String, default: '' },
|
|
22
|
+
layout: { type: String, default: 'admin' },
|
|
23
|
+
props: { type: Object, default: () => ({}) },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const resolved = computed(() => {
|
|
27
|
+
if (!props.component) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const found = resolveComponent(props.component);
|
|
32
|
+
|
|
33
|
+
// resolveComponent hands back the name as a string when nothing is
|
|
34
|
+
// registered under it, which would render as a literal <ComponentName>
|
|
35
|
+
// element rather than as anything useful.
|
|
36
|
+
return typeof found === 'string' ? null : found;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const title = computed(() => props.component || props.plugin || 'Plugin');
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<EscalatedLayout :title="title">
|
|
44
|
+
<component :is="resolved" v-if="resolved" v-bind="props.props" />
|
|
45
|
+
|
|
46
|
+
<div
|
|
47
|
+
v-else
|
|
48
|
+
class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6 text-center"
|
|
49
|
+
>
|
|
50
|
+
<p class="text-sm text-[var(--esc-panel-text-secondary)]">
|
|
51
|
+
The <strong>{{ plugin || 'plugin' }}</strong> plugin registered a page here, but no component named
|
|
52
|
+
<code class="rounded bg-[var(--esc-panel-hover)] px-1">{{ component || '(unnamed)' }}</code> is
|
|
53
|
+
available.
|
|
54
|
+
</p>
|
|
55
|
+
<p class="mt-2 text-xs text-[var(--esc-panel-text-muted)]">
|
|
56
|
+
Register it on the frontend with <code>defineEscalatedPlugin</code> — the backend route exists, so only
|
|
57
|
+
the component is missing.
|
|
58
|
+
</p>
|
|
59
|
+
</div>
|
|
60
|
+
</EscalatedLayout>
|
|
61
|
+
</template>
|