@escalated-dev/escalated 0.11.4 → 0.11.6

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.
@@ -1,15 +1,21 @@
1
1
  <script setup>
2
2
  import { computed } from 'vue';
3
+ import { DEFAULT_OPERATORS, humanize, normalizeOptions } from '../utils/workflowContract.js';
3
4
 
4
5
  const props = defineProps({
6
+ // The editor's `{match, conditions}`; the builder sends it as `{all}` or
7
+ // `{any}` (workflow-admin-contract.md).
5
8
  modelValue: {
6
9
  type: Object,
7
10
  default: () => ({ match: 'all', conditions: [] }),
8
11
  },
12
+ // The operators the backend evaluates. Absent means the contract's twelve.
13
+ operators: { type: [Array, Object], default: null },
9
14
  });
10
15
 
11
16
  const emit = defineEmits(['update:modelValue']);
12
17
 
18
+ // The field names every backend resolves (workflow-admin-contract.md#conditions).
13
19
  const fields = [
14
20
  {
15
21
  value: 'status',
@@ -28,43 +34,48 @@ const fields = [
28
34
  },
29
35
  { value: 'priority', label: 'Priority', type: 'enum', options: ['low', 'medium', 'high', 'urgent', 'critical'] },
30
36
  {
31
- value: 'type',
37
+ value: 'ticket_type',
32
38
  label: 'Type',
33
39
  type: 'enum',
34
40
  options: ['question', 'problem', 'incident', 'task', 'feature_request'],
35
41
  },
36
42
  { value: 'channel', label: 'Channel', type: 'enum', options: ['web', 'email', 'chat', 'api', 'phone'] },
37
- { value: 'tags', label: 'Tags', type: 'text' },
38
- { value: 'department', label: 'Department', type: 'text' },
39
- { value: 'assigned_agent', label: 'Assigned Agent', type: 'text' },
40
43
  { value: 'subject', label: 'Subject', type: 'text' },
44
+ { value: 'description', label: 'Description', type: 'text' },
45
+ { value: 'tags', label: 'Tags', type: 'text' },
46
+ { value: 'department_id', label: 'Department', type: 'text' },
47
+ { value: 'assigned_to', label: 'Assigned Agent', type: 'text' },
41
48
  { value: 'hours_since_created', label: 'Hours Since Created', type: 'number' },
42
49
  { value: 'hours_since_updated', label: 'Hours Since Updated', type: 'number' },
43
- { value: 'reply_count', label: 'Reply Count', type: 'number' },
44
- { value: 'custom_field', label: 'Custom Field', type: 'text' },
45
50
  ];
46
51
 
52
+ const OPERATOR_LABELS = {
53
+ equals: 'equals',
54
+ not_equals: 'not equals',
55
+ contains: 'contains',
56
+ not_contains: 'does not contain',
57
+ starts_with: 'starts with',
58
+ ends_with: 'ends with',
59
+ greater_than: 'greater than',
60
+ less_than: 'less than',
61
+ greater_or_equal: 'at least',
62
+ less_or_equal: 'at most',
63
+ is_empty: 'is empty',
64
+ is_not_empty: 'is not empty',
65
+ };
66
+
47
67
  const operatorsByType = {
48
- enum: [
49
- { value: 'equals', label: 'equals' },
50
- { value: 'not_equals', label: 'not equals' },
51
- { value: 'in', label: 'in' },
52
- ],
53
- text: [
54
- { value: 'equals', label: 'equals' },
55
- { value: 'not_equals', label: 'not equals' },
56
- { value: 'contains', label: 'contains' },
57
- { value: 'is_empty', label: 'is empty' },
58
- { value: 'matches', label: 'matches' },
59
- ],
60
- number: [
61
- { value: 'equals', label: 'equals' },
62
- { value: 'not_equals', label: 'not equals' },
63
- { value: 'greater_than', label: 'greater than' },
64
- { value: 'less_than', label: 'less than' },
65
- ],
68
+ enum: ['equals', 'not_equals', 'is_empty', 'is_not_empty'],
69
+ text: ['equals', 'not_equals', 'contains', 'not_contains', 'starts_with', 'ends_with', 'is_empty', 'is_not_empty'],
70
+ number: ['equals', 'not_equals', 'greater_than', 'less_than', 'greater_or_equal', 'less_or_equal'],
66
71
  };
67
72
 
73
+ const VALUELESS_OPERATORS = ['is_empty', 'is_not_empty'];
74
+
75
+ const backendOperators = computed(() =>
76
+ props.operators ? normalizeOptions(props.operators, []).map((o) => o.value) : null,
77
+ );
78
+
68
79
  const data = computed(() => props.modelValue);
69
80
 
70
81
  function update(patch) {
@@ -94,14 +105,28 @@ function getFieldConfig(fieldValue) {
94
105
  return fields.find((f) => f.value === fieldValue) || fields[0];
95
106
  }
96
107
 
108
+ function operatorLabel(operator) {
109
+ return OPERATOR_LABELS[operator] ?? humanize(operator).toLowerCase();
110
+ }
111
+
112
+ // The operators that make sense for the field. When the backend lists its own,
113
+ // only those are offered: the canonical ones that fit the field, plus any
114
+ // extras it evaluates.
97
115
  function getOperators(fieldValue) {
98
- const field = getFieldConfig(fieldValue);
99
- return operatorsByType[field.type] || operatorsByType.text;
116
+ const forType = operatorsByType[getFieldConfig(fieldValue).type] || operatorsByType.text;
117
+ const listed = backendOperators.value;
118
+ const values = listed ? listed.filter((op) => forType.includes(op) || !DEFAULT_OPERATORS.includes(op)) : forType;
119
+
120
+ return values.map((value) => ({ value, label: operatorLabel(value) }));
121
+ }
122
+
123
+ function valueless(operator) {
124
+ return VALUELESS_OPERATORS.includes(operator);
100
125
  }
101
126
 
102
127
  function onFieldChange(index, newField) {
103
128
  const operators = getOperators(newField);
104
- updateCondition(index, { field: newField, operator: operators[0].value, value: '' });
129
+ updateCondition(index, { field: newField, operator: operators[0]?.value ?? 'equals', value: '' });
105
130
  }
106
131
 
107
132
  const previewText = computed(() => {
@@ -110,11 +135,9 @@ const previewText = computed(() => {
110
135
  return data.value.conditions
111
136
  .map((c) => {
112
137
  const field = getFieldConfig(c.field);
113
- const opLabel =
114
- (operatorsByType[field.type] || []).find((o) => o.value === c.operator)?.label || c.operator;
115
- if (c.operator === 'is_empty') return `${field.label} is empty`;
138
+ if (valueless(c.operator)) return `${field.label} ${operatorLabel(c.operator)}`;
116
139
  const val = Array.isArray(c.value) ? `[${c.value.join(', ')}]` : c.value;
117
- return `${field.label} ${opLabel} ${val}`;
140
+ return `${field.label} ${operatorLabel(c.operator)} ${val}`;
118
141
  })
119
142
  .join(joiner);
120
143
  });
@@ -179,10 +202,10 @@ const previewText = computed(() => {
179
202
  </select>
180
203
 
181
204
  <!-- Value (contextual) -->
182
- <template v-if="condition.operator !== 'is_empty'">
205
+ <template v-if="!valueless(condition.operator)">
183
206
  <!-- Enum select for enum fields -->
184
207
  <select
185
- v-if="getFieldConfig(condition.field).type === 'enum' && condition.operator !== 'in'"
208
+ v-if="getFieldConfig(condition.field).type === 'enum'"
186
209
  :value="condition.value"
187
210
  class="rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)] px-2 py-1.5 text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-border-input)] focus:outline-none"
188
211
  @change="updateCondition(i, { value: $event.target.value })"
@@ -193,21 +216,6 @@ const previewText = computed(() => {
193
216
  </option>
194
217
  </select>
195
218
 
196
- <!-- Multi-select for "in" operator on enum fields -->
197
- <select
198
- v-else-if="getFieldConfig(condition.field).type === 'enum' && condition.operator === 'in'"
199
- :value="condition.value"
200
- multiple
201
- class="min-h-[60px] rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)] px-2 py-1.5 text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-border-input)] focus:outline-none"
202
- @change="
203
- updateCondition(i, { value: Array.from($event.target.selectedOptions, (o) => o.value) })
204
- "
205
- >
206
- <option v-for="opt in getFieldConfig(condition.field).options" :key="opt" :value="opt">
207
- {{ opt }}
208
- </option>
209
- </select>
210
-
211
219
  <!-- Number input -->
212
220
  <input
213
221
  v-else-if="getFieldConfig(condition.field).type === 'number'"
@@ -1,74 +1,70 @@
1
1
  <script setup>
2
2
  import { computed } from 'vue';
3
+ import { DEFAULT_TRIGGER_EVENTS, normalizeOptions } from '../utils/workflowContract.js';
3
4
 
4
5
  const props = defineProps({
5
6
  modelValue: { type: String, default: '' },
7
+ // The events the backend fires, in any shape normalizeOptions accepts.
8
+ // Absent means the contract's five canonical triggers.
9
+ options: { type: [Array, Object], default: null },
6
10
  });
7
11
 
8
12
  const emit = defineEmits(['update:modelValue']);
9
13
 
10
- const triggers = [
11
- {
12
- value: 'ticket_created',
13
- label: 'Ticket Created',
14
+ const REPLY_ICON =
15
+ 'M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z';
16
+ const DEFAULT_ICON = 'M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z';
17
+
18
+ // Descriptions and icons for the events backends are known to fire. An event
19
+ // not listed here still renders, with its derived label and a generic icon.
20
+ const DETAILS = {
21
+ 'ticket.created': {
14
22
  description: 'When a new ticket is submitted',
15
23
  icon: 'M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z',
16
24
  },
17
- {
18
- value: 'ticket_updated',
19
- label: 'Ticket Updated',
25
+ 'ticket.updated': {
20
26
  description: 'When any ticket field is modified',
21
27
  icon: 'M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182',
22
28
  },
23
- {
24
- value: 'reply_added',
25
- label: 'Reply Added',
26
- description: 'When a reply is posted on a ticket',
27
- icon: 'M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z',
29
+ 'ticket.assigned': {
30
+ description: 'When a ticket is assigned to an agent',
31
+ icon: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z',
28
32
  },
29
- {
30
- value: 'status_changed',
31
- label: 'Status Changed',
33
+ 'ticket.status_changed': {
32
34
  description: 'When the ticket status is updated',
33
35
  icon: 'M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
34
36
  },
35
- {
36
- value: 'ticket_assigned',
37
- label: 'Ticket Assigned',
38
- description: 'When a ticket is assigned to an agent',
39
- icon: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z',
40
- },
41
- {
42
- value: 'ticket_escalated',
43
- label: 'Ticket Escalated',
37
+ 'reply.created': { description: 'When a reply is posted on a ticket', icon: REPLY_ICON },
38
+ 'ticket.replied': { description: 'When a reply is posted on a ticket', icon: REPLY_ICON },
39
+ 'ticket.escalated': {
44
40
  description: 'When a ticket is escalated',
45
41
  icon: 'M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12',
46
42
  },
47
- {
48
- value: 'sla_breached',
49
- label: 'SLA Breached',
43
+ 'sla.breached': {
50
44
  description: 'When an SLA target is breached',
51
45
  icon: 'M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z',
52
46
  },
53
- {
54
- value: 'sla_warning',
55
- label: 'SLA Warning',
47
+ 'sla.warning': {
56
48
  description: 'When an SLA target is approaching',
57
49
  icon: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z',
58
50
  },
59
- {
60
- value: 'chat_started',
61
- label: 'Chat Started',
51
+ 'chat.started': {
62
52
  description: 'When a live chat session begins',
63
53
  icon: 'M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155',
64
54
  },
65
- {
66
- value: 'chat_ended',
67
- label: 'Chat Ended',
55
+ 'chat.ended': {
68
56
  description: 'When a live chat session ends',
69
57
  icon: 'M15 11.25l-3-3m0 0l-3 3m3-3v7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
70
58
  },
71
- ];
59
+ };
60
+
61
+ const triggers = computed(() =>
62
+ normalizeOptions(props.options, DEFAULT_TRIGGER_EVENTS).map((option) => ({
63
+ ...option,
64
+ description: DETAILS[option.value]?.description ?? '',
65
+ icon: DETAILS[option.value]?.icon ?? DEFAULT_ICON,
66
+ })),
67
+ );
72
68
 
73
69
  const selected = computed(() => props.modelValue);
74
70
 
@@ -109,7 +105,7 @@ function select(value) {
109
105
  >
110
106
  {{ trigger.label }}
111
107
  </span>
112
- <span class="mt-1 text-[10px] leading-tight text-[var(--esc-panel-text-muted)]">
108
+ <span v-if="trigger.description" class="mt-1 text-[10px] leading-tight text-[var(--esc-panel-text-muted)]">
113
109
  {{ trigger.description }}
114
110
  </span>
115
111
  <div
@@ -5,18 +5,26 @@ import WorkflowConditionBuilder from '../../../components/WorkflowConditionBuild
5
5
  import WorkflowActionList from '../../../components/WorkflowActionList.vue';
6
6
  import { useForm } from '@inertiajs/vue3';
7
7
  import { ref, computed } from 'vue';
8
+ import { toActionState, toConditionState, toWirePayload } from '../../../utils/workflowContract.js';
8
9
 
9
10
  const props = defineProps({
10
11
  workflow: { type: Object, default: null },
12
+ // What the backend fires, executes and evaluates. Any shape
13
+ // normalizeOptions accepts; absent means the contract's defaults.
14
+ triggerEvents: { type: [Array, Object], default: null },
15
+ actionTypes: { type: [Array, Object], default: null },
16
+ operators: { type: [Array, Object], default: null },
11
17
  });
12
18
 
19
+ // The form holds the editor's state; submit() sends it in the shape every
20
+ // backend reads (escalated-developer-context domain-model/workflow-admin-contract.md).
13
21
  const form = useForm({
14
22
  name: props.workflow?.name || '',
15
23
  description: props.workflow?.description || '',
16
- trigger: props.workflow?.trigger || '',
17
- conditions: props.workflow?.conditions || { match: 'all', conditions: [] },
18
- actions: props.workflow?.actions || [],
19
- active: props.workflow?.active ?? true,
24
+ trigger_event: props.workflow?.trigger_event || props.workflow?.trigger || '',
25
+ conditions: toConditionState(props.workflow?.conditions),
26
+ actions: toActionState(props.workflow?.actions),
27
+ is_active: props.workflow?.is_active ?? props.workflow?.active ?? true,
20
28
  });
21
29
 
22
30
  const expandedStep = ref(props.workflow ? null : 1);
@@ -26,6 +34,8 @@ function toggleStep(step) {
26
34
  }
27
35
 
28
36
  function submit() {
37
+ form.transform(toWirePayload);
38
+
29
39
  if (props.workflow) {
30
40
  form.put(route('escalated.admin.workflows.update', props.workflow.id));
31
41
  } else {
@@ -34,7 +44,7 @@ function submit() {
34
44
  }
35
45
 
36
46
  const isValid = computed(() => {
37
- return form.name && form.trigger && form.actions.length > 0;
47
+ return form.name && form.trigger_event && form.actions.length > 0;
38
48
  });
39
49
 
40
50
  const stepBorderColors = {
@@ -64,7 +74,7 @@ const stepDescriptions = {
64
74
  function stepHasData(step) {
65
75
  switch (step) {
66
76
  case 1:
67
- return !!form.trigger;
77
+ return !!form.trigger_event;
68
78
  case 2:
69
79
  return form.conditions?.conditions?.length > 0;
70
80
  case 3:
@@ -108,7 +118,7 @@ function stepHasData(step) {
108
118
  <div>
109
119
  <label class="flex items-center gap-2 text-sm text-[var(--esc-panel-text-secondary)]">
110
120
  <input
111
- v-model="form.active"
121
+ v-model="form.is_active"
112
122
  type="checkbox"
113
123
  class="rounded border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)] text-cyan-500 focus:ring-cyan-500/20"
114
124
  />
@@ -175,13 +185,17 @@ function stepHasData(step) {
175
185
  <!-- Step content -->
176
186
  <div v-if="expandedStep === step" class="border-t border-[var(--esc-panel-border)] px-6 py-4">
177
187
  <!-- Trigger selector -->
178
- <WorkflowTriggerSelector v-if="step === 1" v-model="form.trigger" />
188
+ <WorkflowTriggerSelector
189
+ v-if="step === 1"
190
+ v-model="form.trigger_event"
191
+ :options="triggerEvents"
192
+ />
179
193
 
180
194
  <!-- Condition builder -->
181
- <WorkflowConditionBuilder v-if="step === 2" v-model="form.conditions" />
195
+ <WorkflowConditionBuilder v-if="step === 2" v-model="form.conditions" :operators="operators" />
182
196
 
183
197
  <!-- Action list -->
184
- <WorkflowActionList v-if="step === 3" v-model="form.actions" />
198
+ <WorkflowActionList v-if="step === 3" v-model="form.actions" :action-types="actionTypes" />
185
199
  </div>
186
200
  </div>
187
201
  </div>
@@ -4,21 +4,33 @@ import Builder from './Builder.vue';
4
4
  /**
5
5
  * The workflow editor, under the name the backends render.
6
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.
7
+ * Builder.vue is the editor itself. Several backends render
8
+ * `Escalated/Admin/Workflows/Form` for both create and edit, and a page name
9
+ * with no component behind it is not an error in Inertia -- the panel simply
10
+ * comes up blank. This is that name.
11
11
  */
12
12
  defineProps({
13
13
  workflow: { type: Object, default: null },
14
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 },
15
+ // What the backend fires, evaluates and executes, passed alongside the
16
+ // workflow (escalated-developer-context domain-model/workflow-admin-contract.md).
17
+ // The builder offers exactly these, so they are handed on.
18
+ //
19
+ // Declared in snake_case because that is what arrives. Vue folds
20
+ // kebab-case into camelCase and nothing else, so a `triggerEvents`
21
+ // declaration never matched the `trigger_events` the backends send -- it
22
+ // landed on Builder's root element as an attribute.
23
+ trigger_events: { type: [Array, Object], default: null },
24
+ operators: { type: [Array, Object], default: null },
25
+ action_types: { type: [Array, Object], default: null },
19
26
  });
20
27
  </script>
21
28
 
22
29
  <template>
23
- <Builder :workflow="workflow" />
30
+ <Builder
31
+ :workflow="workflow"
32
+ :trigger-events="trigger_events"
33
+ :action-types="action_types"
34
+ :operators="operators"
35
+ />
24
36
  </template>
@@ -2,6 +2,7 @@
2
2
  import EscalatedLayout from '../../../components/EscalatedLayout.vue';
3
3
  import { Link, router } from '@inertiajs/vue3';
4
4
  import { ref } from 'vue';
5
+ import { humanize } from '../../../utils/workflowContract.js';
5
6
 
6
7
  defineProps({
7
8
  workflows: { type: Array, default: () => [] },
@@ -9,6 +10,16 @@ defineProps({
9
10
 
10
11
  const dragIndex = ref(null);
11
12
 
13
+ // Backends send `trigger_event` and `is_active` (workflow-admin-contract.md).
14
+ // `trigger` and `active` are read too, for anything still sending the aliases.
15
+ function triggerOf(workflow) {
16
+ return workflow.trigger_event ?? workflow.trigger ?? '';
17
+ }
18
+
19
+ function isActive(workflow) {
20
+ return workflow.is_active ?? workflow.active ?? false;
21
+ }
22
+
12
23
  function destroy(id) {
13
24
  if (confirm('Delete this workflow? This cannot be undone.')) {
14
25
  router.delete(route('escalated.admin.workflows.destroy', id));
@@ -16,9 +27,7 @@ function destroy(id) {
16
27
  }
17
28
 
18
29
  function toggleActive(workflow) {
19
- router.put(route('escalated.admin.workflows.toggle', workflow.id), {
20
- active: !workflow.active,
21
- });
30
+ router.post(route('escalated.admin.workflows.toggle', workflow.id));
22
31
  }
23
32
 
24
33
  function onDragStart(index) {
@@ -35,7 +44,7 @@ function onDrop(event, workflows, targetIndex) {
35
44
  const ids = workflows.map((w) => w.id);
36
45
  const [moved] = ids.splice(dragIndex.value, 1);
37
46
  ids.splice(targetIndex, 0, moved);
38
- router.post(route('escalated.admin.workflows.reorder'), { ids });
47
+ router.post(route('escalated.admin.workflows.reorder'), { workflow_ids: ids });
39
48
  dragIndex.value = null;
40
49
  }
41
50
 
@@ -45,23 +54,20 @@ function onDragEnd() {
45
54
 
46
55
  function triggerBadgeClass(trigger) {
47
56
  const classes = {
48
- ticket_created: 'bg-blue-500/10 text-blue-400',
49
- ticket_updated: 'bg-cyan-500/10 text-cyan-400',
50
- reply_added: 'bg-emerald-500/10 text-emerald-400',
51
- status_changed: 'bg-violet-500/10 text-violet-400',
52
- ticket_assigned: 'bg-indigo-500/10 text-indigo-400',
53
- ticket_escalated: 'bg-red-500/10 text-red-400',
54
- sla_breached: 'bg-rose-500/10 text-rose-400',
55
- sla_warning: 'bg-amber-500/10 text-amber-400',
56
- chat_started: 'bg-teal-500/10 text-teal-400',
57
- chat_ended: 'bg-gray-500/10 text-gray-400',
57
+ 'ticket.created': 'bg-blue-500/10 text-blue-400',
58
+ 'ticket.updated': 'bg-cyan-500/10 text-cyan-400',
59
+ 'reply.created': 'bg-emerald-500/10 text-emerald-400',
60
+ 'ticket.replied': 'bg-emerald-500/10 text-emerald-400',
61
+ 'ticket.status_changed': 'bg-violet-500/10 text-violet-400',
62
+ 'ticket.assigned': 'bg-indigo-500/10 text-indigo-400',
63
+ 'ticket.escalated': 'bg-red-500/10 text-red-400',
64
+ 'sla.breached': 'bg-rose-500/10 text-rose-400',
65
+ 'sla.warning': 'bg-amber-500/10 text-amber-400',
66
+ 'chat.started': 'bg-teal-500/10 text-teal-400',
67
+ 'chat.ended': 'bg-gray-500/10 text-gray-400',
58
68
  };
59
69
  return classes[trigger] || 'bg-gray-500/10 text-gray-400';
60
70
  }
61
-
62
- function triggerLabel(trigger) {
63
- return (trigger || '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
64
- }
65
71
  </script>
66
72
 
67
73
  <template>
@@ -192,21 +198,21 @@ function triggerLabel(trigger) {
192
198
  <td class="px-4 py-3">
193
199
  <span
194
200
  class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium"
195
- :class="triggerBadgeClass(workflow.trigger)"
201
+ :class="triggerBadgeClass(triggerOf(workflow))"
196
202
  >
197
- {{ triggerLabel(workflow.trigger) }}
203
+ {{ humanize(triggerOf(workflow)) }}
198
204
  </span>
199
205
  </td>
200
206
  <td class="px-4 py-3">
201
207
  <button
202
208
  type="button"
203
209
  class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
204
- :class="workflow.active ? 'bg-emerald-500' : 'bg-[var(--esc-panel-border)]'"
210
+ :class="isActive(workflow) ? 'bg-emerald-500' : 'bg-[var(--esc-panel-border)]'"
205
211
  @click="toggleActive(workflow)"
206
212
  >
207
213
  <span
208
214
  class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
209
- :class="workflow.active ? 'translate-x-4' : 'translate-x-0'"
215
+ :class="isActive(workflow) ? 'translate-x-4' : 'translate-x-0'"
210
216
  />
211
217
  </button>
212
218
  </td>
@@ -0,0 +1,69 @@
1
+ <script setup>
2
+ import { Link } from '@inertiajs/vue3';
3
+ import { computed } from 'vue';
4
+
5
+ /**
6
+ * The refusal and not-found screen.
7
+ *
8
+ * Backends render this instead of an HTML error page so a 403 or a 404 inside
9
+ * the panel stays inside the panel. It deliberately does not use
10
+ * EscalatedLayout: the most common reason to arrive here is that the viewer was
11
+ * refused, and the layout's navigation is a list of places they may equally not
12
+ * be allowed to go. Rendering it would offer a menu that mostly leads back
13
+ * here.
14
+ *
15
+ * The message comes from the backend, already translated. The headings below
16
+ * are the fallback for a status arriving with no message at all.
17
+ */
18
+ const props = defineProps({
19
+ status: { type: [Number, String], default: 500 },
20
+ message: { type: String, default: '' },
21
+
22
+ // Where "back" goes. A host mounts Escalated under its own prefix, and this
23
+ // page is reached without a layout to resolve one.
24
+ home: { type: String, default: '' },
25
+ });
26
+
27
+ const code = computed(() => String(props.status));
28
+
29
+ const title = computed(
30
+ () =>
31
+ ({
32
+ 403: 'Not allowed',
33
+ 404: 'Not found',
34
+ 419: 'Session expired',
35
+ 429: 'Too many requests',
36
+ 500: 'Something went wrong',
37
+ 503: 'Temporarily unavailable',
38
+ })[code.value] || 'Something went wrong',
39
+ );
40
+
41
+ const explanation = computed(
42
+ () =>
43
+ ({
44
+ 403: 'Your account does not have access to this.',
45
+ 404: 'That page does not exist, or it has been removed.',
46
+ 419: 'Your session expired. Sign in again to continue.',
47
+ 429: 'Too many requests in a short space of time. Wait a moment and try again.',
48
+ 503: 'The service is down for maintenance. Try again shortly.',
49
+ })[code.value] || 'The request could not be completed.',
50
+ );
51
+ </script>
52
+
53
+ <template>
54
+ <div class="flex min-h-screen items-center justify-center bg-[var(--esc-panel-bg)] px-4">
55
+ <div class="w-full max-w-md text-center">
56
+ <p class="font-mono text-sm tracking-widest text-[var(--esc-panel-text-muted)]">{{ code }}</p>
57
+ <h1 class="mt-2 text-xl font-semibold text-[var(--esc-panel-text-secondary)]">{{ title }}</h1>
58
+ <p class="mt-3 text-sm text-[var(--esc-panel-text-muted)]">{{ message || explanation }}</p>
59
+
60
+ <Link
61
+ v-if="home"
62
+ :href="home"
63
+ class="mt-6 inline-block rounded-lg border border-[var(--esc-panel-border)] px-4 py-2 text-sm text-[var(--esc-panel-text-tertiary)] transition-colors hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]"
64
+ >
65
+ Back to the panel
66
+ </Link>
67
+ </div>
68
+ </div>
69
+ </template>