@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 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
- [![Tests](https://github.com/escalated-dev/escalated/actions/workflows/run-tests.yml/badge.svg)](https://github.com/escalated-dev/escalated/actions/workflows/run-tests.yml)
24
- [![FOSSA Status](https://app.fossa.com/api/projects/custom%2B62107%2Fgithub.com%2Fescalated-dev%2Fescalated.svg?type=shield)](https://app.fossa.com/projects/custom%2B62107%2Fgithub.com%2Fescalated-dev%2Fescalated?ref=badge_shield)
25
- [![npm](https://img.shields.io/npm/v/@escalated-dev/escalated)](https://www.npmjs.com/package/@escalated-dev/escalated)
23
+ <div align="center">
24
+
25
+ [![Tests](https://github.com/escalated-dev/escalated/actions/workflows/run-tests.yml/badge.svg)](https://github.com/escalated-dev/escalated/actions/workflows/run-tests.yml)[![npm](https://img.shields.io/npm/v/@escalated-dev/escalated)](https://www.npmjs.com/package/@escalated-dev/escalated)
26
26
  [![Vue 3](https://img.shields.io/badge/vue-3.x-4FC08D?logo=vue.js&logoColor=white)](https://vuejs.org/)
27
27
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
28
28
 
29
+ </div>
30
+
29
31
  <p align="center">
30
- <img src=".github/profile/demo.gif" alt="Escalated demo: filing a ticket and replying from the agent inbox" width="800" />
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,85 +1,88 @@
1
1
  {
2
- "name": "@escalated-dev/escalated",
3
- "version": "0.9.0",
4
- "description": "Vue 3 + Inertia.js UI components for Escalated \u00e2\u20ac\u201d the embeddable support ticket system",
5
- "author": "Escalated Dev <hello@escalated.dev>",
6
- "license": "MIT",
7
- "homepage": "https://github.com/escalated-dev/escalated",
8
- "repository": {
9
- "type": "git",
10
- "url": "https://github.com/escalated-dev/escalated.git"
11
- },
12
- "keywords": [
13
- "vue",
14
- "inertia",
15
- "support",
16
- "tickets",
17
- "helpdesk",
18
- "escalated"
19
- ],
20
- "type": "module",
21
- "exports": {
22
- "./components/*": "./src/components/*",
23
- "./pages/*": "./src/pages/*",
24
- "./locales": "./src/locales/index.js",
25
- "./locales/*": "./src/locales/*",
26
- ".": "./src/index.js"
27
- },
28
- "files": [
29
- "src"
30
- ],
31
- "peerDependencies": {
32
- "@inertiajs/vue3": "^1.0.0 || ^2.0.0 || ^3.0.0",
33
- "vue": "^3.3.0"
34
- },
35
- "dependencies": {
36
- "@escalated-dev/locale": "github:escalated-dev/escalated-locale#v0.1.8"
37
- },
38
- "scripts": {
39
- "test": "vitest run",
40
- "lint": "eslint src/ --max-warnings 0",
41
- "lint:fix": "eslint src/ --fix",
42
- "format": "prettier --write src/",
43
- "format:check": "prettier --check src/",
44
- "prepare": "husky",
45
- "storybook": "storybook dev -p 6006",
46
- "build-storybook": "storybook build",
47
- "screenshot": "npx playwright test --config=playwright-screenshots.config.js",
48
- "demo:serve": "node scripts/demo-server.js",
49
- "demo:record": "node scripts/generate-demo.js",
50
- "demo": "concurrently -k -s second \"npm run demo:serve\" \"wait-on http://localhost:3000 && npm run demo:record\""
51
- },
52
- "lint-staged": {
53
- "*.{js,vue}": [
54
- "eslint --fix",
55
- "prettier --write"
56
- ]
57
- },
58
- "devDependencies": {
59
- "@eslint/js": "^10.0.1",
60
- "@inertiajs/vue3": "^3.2.0",
61
- "@playwright/test": "^1.50.0",
62
- "@storybook/addon-a11y": "^10.2.12",
63
- "@storybook/addon-docs": "^10.2.12",
64
- "@storybook/vue3-vite": "^10.2.12",
65
- "@tailwindcss/postcss": "^4.3.0",
66
- "@vitejs/plugin-vue": "^6.0.6",
67
- "@vue/test-utils": "^2.4.0",
68
- "autoprefixer": "^10.4.20",
69
- "concurrently": "^9.0.0",
70
- "eslint": "^10.0.1",
71
- "eslint-plugin-storybook": "^10.2.12",
72
- "eslint-plugin-vue": "^10.8.0",
73
- "happy-dom": "^20.9.0",
74
- "husky": "^9.1.7",
75
- "lint-staged": "^17.0.5",
76
- "playwright": "^1.50.0",
77
- "postcss": "^8.5.0",
78
- "prettier": "^3.8.1",
79
- "storybook": "^10.2.12",
80
- "tailwindcss": "^4.3.0",
81
- "vitest": "^4.1.6",
82
- "vue": "^3.5.0",
83
- "wait-on": "^9.0.5"
84
- }
2
+ "name": "@escalated-dev/escalated",
3
+ "version": "0.11.2",
4
+ "description": "Vue 3 + Inertia.js UI components for Escalated — the embeddable support ticket system",
5
+ "author": "Escalated Dev <hello@escalated.dev>",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/escalated-dev/escalated",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/escalated-dev/escalated.git"
11
+ },
12
+ "keywords": [
13
+ "vue",
14
+ "inertia",
15
+ "support",
16
+ "tickets",
17
+ "helpdesk",
18
+ "escalated"
19
+ ],
20
+ "type": "module",
21
+ "exports": {
22
+ "./components/*": "./src/components/*",
23
+ "./pages/*": "./src/pages/*",
24
+ "./locales": "./src/locales/index.js",
25
+ "./locales/*": "./src/locales/*",
26
+ ".": "./src/index.js",
27
+ "./pages.json": "./pages.json"
28
+ },
29
+ "files": [
30
+ "src"
31
+ ],
32
+ "peerDependencies": {
33
+ "@inertiajs/vue3": "^1.0.0 || ^2.0.0 || ^3.0.0",
34
+ "vue": "^3.3.0"
35
+ },
36
+ "dependencies": {
37
+ "@escalated-dev/locale": "^0.1.8"
38
+ },
39
+ "scripts": {
40
+ "test": "vitest run",
41
+ "lint": "eslint src/ --max-warnings 0",
42
+ "lint:fix": "eslint src/ --fix",
43
+ "format": "prettier --write src/",
44
+ "format:check": "prettier --check src/",
45
+ "prepare": "husky",
46
+ "storybook": "storybook dev -p 6006",
47
+ "build-storybook": "storybook build",
48
+ "screenshot": "npx playwright test --config=playwright-screenshots.config.js",
49
+ "demo:serve": "node scripts/demo-server.js",
50
+ "demo:record": "node scripts/generate-demo.js",
51
+ "demo": "concurrently -k -s second \"npm run demo:serve\" \"wait-on http://localhost:3000 && npm run demo:record\"",
52
+ "pages:manifest": "node scripts/generate-page-manifest.js",
53
+ "pages:check": "node scripts/generate-page-manifest.js --check"
54
+ },
55
+ "lint-staged": {
56
+ "*.{js,vue}": [
57
+ "eslint --fix",
58
+ "prettier --write"
59
+ ]
60
+ },
61
+ "devDependencies": {
62
+ "@eslint/js": "^10.0.1",
63
+ "@inertiajs/vue3": "^3.7.0",
64
+ "@playwright/test": "^1.62.1",
65
+ "@storybook/addon-a11y": "^10.6.0",
66
+ "@storybook/addon-docs": "^10.6.0",
67
+ "@storybook/vue3-vite": "^10.6.0",
68
+ "@tailwindcss/postcss": "^4.3.3",
69
+ "@vitejs/plugin-vue": "^6.0.8",
70
+ "@vue/test-utils": "^2.5.0",
71
+ "autoprefixer": "^10.5.4",
72
+ "concurrently": "^10.0.5",
73
+ "eslint": "^10.9.1",
74
+ "eslint-plugin-storybook": "^10.5.10",
75
+ "eslint-plugin-vue": "^10.10.0",
76
+ "happy-dom": "^20.14.0",
77
+ "husky": "^9.1.7",
78
+ "lint-staged": "^17.4.1",
79
+ "playwright": "^1.62.1",
80
+ "postcss": "^8.5.26",
81
+ "prettier": "^3.9.6",
82
+ "storybook": "^10.5.10",
83
+ "tailwindcss": "^4.3.3",
84
+ "vitest": "^5.0.0",
85
+ "vue": "^3.5.42",
86
+ "wait-on": "^9.1.0"
87
+ }
85
88
  }
@@ -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',
package/src/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  // Plugin
2
2
  export { EscalatedPlugin } from './plugin';
3
3
 
4
+ // Page resolution. A page name with no component behind it resolves to nothing
5
+ // in Inertia -- a 200 and a blank panel -- so this says so instead.
6
+ export { resolveEscalatedPage, isEscalatedPage, escalatedPages, suggestPages } from './resolvePage';
7
+
4
8
  // Components
5
9
  export { default as ActiveChatsPanel } from './components/ActiveChatsPanel.vue';
6
10
  export { default as ActivityTimeline } from './components/ActivityTimeline.vue';
@@ -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
+ &larr; 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>