@escalated-dev/escalated 0.9.0 → 0.11.2
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 +86 -83
- package/src/components/EscalatedLayout.vue +9 -0
- package/src/index.js +4 -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/src/resolvePage.js +160 -0
|
@@ -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>
|