@escalated-dev/escalated 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -0
- package/package.json +37 -6
- package/src/components/ActivityTimeline.stories.js +55 -0
- package/src/components/ActivityTimeline.vue +83 -66
- package/src/components/AdminDashboard.stories.js +1095 -0
- package/src/components/AgentLoadIndicator.stories.js +37 -0
- package/src/components/AgentLoadIndicator.vue +49 -0
- package/src/components/AgentPerformanceCard.vue +44 -0
- package/src/components/ArticleEditor.vue +82 -0
- package/src/components/AssigneeSelect.vue +37 -23
- package/src/components/AttachmentList.vue +53 -37
- package/src/components/AuditLogEntry.vue +95 -0
- package/src/components/BulkActionBar.vue +99 -30
- package/src/components/CategoryTree.vue +89 -0
- package/src/components/ChartWidget.vue +46 -0
- package/src/components/CollisionWarning.stories.js +30 -0
- package/src/components/CollisionWarning.vue +68 -0
- package/src/components/ConditionalFieldRules.vue +135 -0
- package/src/components/ContextPanel.vue +66 -0
- package/src/components/ContextPanelSection.vue +65 -0
- package/src/components/CustomFieldRenderer.vue +159 -0
- package/src/components/EscalatedLayout.vue +400 -69
- package/src/components/FileDropzone.vue +57 -41
- package/src/components/FollowButton.stories.js +30 -0
- package/src/components/FollowButton.vue +53 -28
- package/src/components/KeyboardShortcutHelp.vue +129 -39
- package/src/components/KnowledgePanel.vue +122 -0
- package/src/components/KpiCard.stories.js +32 -0
- package/src/components/KpiCard.vue +88 -0
- package/src/components/MacroDropdown.vue +95 -35
- package/src/components/PermissionMatrix.vue +107 -0
- package/src/components/PinnedNotes.vue +75 -33
- package/src/components/PresenceIndicator.vue +93 -27
- package/src/components/PriorityBadge.stories.js +33 -0
- package/src/components/PriorityBadge.vue +46 -39
- package/src/components/QuickFilters.vue +65 -49
- package/src/components/ReplyComposer.vue +273 -159
- package/src/components/ReplyThread.vue +78 -23
- package/src/components/SatisfactionRating.vue +98 -46
- package/src/components/ScheduleEditor.vue +104 -0
- package/src/components/SideConversation.vue +165 -0
- package/src/components/SideConversationList.vue +147 -0
- package/src/components/SkillTagManager.vue +79 -0
- package/src/components/SlaComplianceChart.vue +53 -0
- package/src/components/SlaTimer.stories.js +35 -0
- package/src/components/SlaTimer.vue +62 -49
- package/src/components/StatsCard.stories.js +24 -0
- package/src/components/StatsCard.vue +58 -46
- package/src/components/StatusBadge.stories.js +53 -0
- package/src/components/StatusBadge.vue +78 -46
- package/src/components/TagSelect.vue +84 -53
- package/src/components/TicketFilters.vue +225 -69
- package/src/components/TicketLinkPanel.vue +193 -0
- package/src/components/TicketList.vue +594 -193
- package/src/components/TicketMergeDialog.vue +157 -0
- package/src/components/TicketSidebar.vue +167 -99
- package/src/components/TicketTypeSelector.vue +21 -0
- package/src/components/TotpInput.stories.js +9 -0
- package/src/components/TotpInput.vue +81 -0
- package/src/components/TwoFactorSetup.vue +113 -0
- package/src/composables/useDebouncedSearch.js +38 -0
- package/src/composables/useI18n.js +80 -0
- package/src/composables/usePluginExtensions.js +95 -3
- package/src/composables/usePluginHooks.js +65 -0
- package/src/defineEscalatedPlugin.js +34 -8
- package/src/index.js +72 -35
- package/src/locales/de.json +442 -0
- package/src/locales/en.json +467 -0
- package/src/locales/es.json +442 -0
- package/src/locales/fr.json +442 -0
- package/src/locales/index.js +6 -0
- package/src/pages/Admin/ApiTokens/Index.vue +231 -67
- package/src/pages/Admin/AuditLog/Index.vue +154 -0
- package/src/pages/Admin/Automations/Form.vue +184 -0
- package/src/pages/Admin/Automations/Index.vue +129 -0
- package/src/pages/Admin/BusinessHours/Form.vue +172 -0
- package/src/pages/Admin/BusinessHours/Index.vue +113 -0
- package/src/pages/Admin/CannedResponses/Index.vue +127 -70
- package/src/pages/Admin/Capacity/Index.vue +134 -0
- package/src/pages/Admin/CustomFields/Form.vue +195 -0
- package/src/pages/Admin/CustomFields/Index.vue +134 -0
- package/src/pages/Admin/CustomObjects/Form.vue +202 -0
- package/src/pages/Admin/CustomObjects/Index.vue +127 -0
- package/src/pages/Admin/CustomObjects/Records.vue +228 -0
- package/src/pages/Admin/Departments/Form.vue +75 -51
- package/src/pages/Admin/Departments/Index.vue +109 -57
- package/src/pages/Admin/EscalationRules/Form.vue +157 -96
- package/src/pages/Admin/EscalationRules/Index.vue +178 -57
- package/src/pages/Admin/Import/Connect.vue +96 -0
- package/src/pages/Admin/Import/Index.vue +102 -0
- package/src/pages/Admin/Import/Mapping.vue +110 -0
- package/src/pages/Admin/Import/Progress.vue +153 -0
- package/src/pages/Admin/Import/Review.vue +76 -0
- package/src/pages/Admin/KnowledgeBase/Articles/Form.vue +142 -0
- package/src/pages/Admin/KnowledgeBase/Articles/Index.vue +195 -0
- package/src/pages/Admin/KnowledgeBase/Categories/Index.vue +193 -0
- package/src/pages/Admin/Macros/Index.vue +250 -89
- package/src/pages/Admin/Plugins/Index.vue +205 -75
- package/src/pages/Admin/Reports/AgentMetrics.vue +115 -0
- package/src/pages/Admin/Reports/CsatReport.vue +127 -0
- package/src/pages/Admin/Reports/Dashboard.vue +211 -0
- package/src/pages/Admin/Reports/SlaReport.vue +166 -0
- package/src/pages/Admin/Reports.vue +139 -34
- package/src/pages/Admin/Roles/Form.vue +71 -0
- package/src/pages/Admin/Roles/Index.vue +122 -0
- package/src/pages/Admin/Settings/CsatSettings.vue +165 -0
- package/src/pages/Admin/Settings/DataRetention.vue +182 -0
- package/src/pages/Admin/Settings/EmailSettings.vue +243 -0
- package/src/pages/Admin/Settings/Sandbox.vue +135 -0
- package/src/pages/Admin/Settings/SsoSettings.vue +218 -0
- package/src/pages/Admin/Settings/TwoFactor.vue +110 -0
- package/src/pages/Admin/Settings.vue +468 -260
- package/src/pages/Admin/Skills/Form.vue +53 -0
- package/src/pages/Admin/Skills/Index.vue +94 -0
- package/src/pages/Admin/SlaPolicies/Form.vue +118 -75
- package/src/pages/Admin/SlaPolicies/Index.vue +120 -59
- package/src/pages/Admin/Statuses/Form.vue +114 -0
- package/src/pages/Admin/Statuses/Index.vue +153 -0
- package/src/pages/Admin/Tags/Index.vue +191 -105
- package/src/pages/Admin/Tickets/Index.vue +91 -39
- package/src/pages/Admin/Tickets/Show.vue +101 -37
- package/src/pages/Admin/Webhooks/DeliveryLog.vue +120 -0
- package/src/pages/Admin/Webhooks/Form.vue +110 -0
- package/src/pages/Admin/Webhooks/Index.vue +136 -0
- package/src/pages/Agent/Dashboard.vue +311 -186
- package/src/pages/Agent/TicketIndex.vue +88 -38
- package/src/pages/Agent/TicketShow.vue +258 -51
- package/src/pages/Customer/Create.vue +36 -9
- package/src/pages/Customer/Index.vue +31 -24
- package/src/pages/Customer/KnowledgeBase/Article.vue +127 -0
- package/src/pages/Customer/KnowledgeBase/Index.vue +151 -0
- package/src/plugin.js +86 -1
- package/src/utils/constants.js +30 -0
- package/src/utils/formatting.js +35 -0
- package/src/utils/sanitizeHtml.js +64 -0
- package/src/pages/Admin/Plugins.vue +0 -129
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
categories: { type: Array, default: () => [] },
|
|
6
|
+
level: { type: Number, default: 0 },
|
|
7
|
+
parentId: { type: [Number, null], default: null },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
defineEmits(['edit', 'delete']);
|
|
11
|
+
|
|
12
|
+
const children = computed(() => {
|
|
13
|
+
return props.categories.filter((c) => {
|
|
14
|
+
if (props.parentId === null) return !c.parent_id;
|
|
15
|
+
return c.parent_id === props.parentId;
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div>
|
|
22
|
+
<div v-for="category in children" :key="category.id">
|
|
23
|
+
<div
|
|
24
|
+
:class="[
|
|
25
|
+
'flex items-center justify-between rounded-lg border border-white/[0.06] bg-neutral-900/60 px-4 py-3 mb-1.5',
|
|
26
|
+
]"
|
|
27
|
+
:style="{ marginLeft: level * 24 + 'px' }"
|
|
28
|
+
>
|
|
29
|
+
<div class="flex items-center gap-3">
|
|
30
|
+
<svg
|
|
31
|
+
class="h-4 w-4 text-neutral-600"
|
|
32
|
+
fill="none"
|
|
33
|
+
stroke="currentColor"
|
|
34
|
+
stroke-width="1.5"
|
|
35
|
+
viewBox="0 0 24 24"
|
|
36
|
+
>
|
|
37
|
+
<path
|
|
38
|
+
stroke-linecap="round"
|
|
39
|
+
stroke-linejoin="round"
|
|
40
|
+
d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
|
|
41
|
+
/>
|
|
42
|
+
</svg>
|
|
43
|
+
<div>
|
|
44
|
+
<span class="text-sm font-medium text-neutral-200">{{ category.name }}</span>
|
|
45
|
+
<span v-if="category.articles_count !== undefined" class="ml-2 text-xs text-neutral-500">
|
|
46
|
+
{{ category.articles_count }} articles
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="flex items-center gap-2">
|
|
51
|
+
<span class="text-xs text-neutral-600">pos: {{ category.position }}</span>
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
class="rounded p-1 text-neutral-500 hover:bg-white/[0.06] hover:text-neutral-300"
|
|
55
|
+
@click="$emit('edit', category)"
|
|
56
|
+
>
|
|
57
|
+
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
|
58
|
+
<path
|
|
59
|
+
stroke-linecap="round"
|
|
60
|
+
stroke-linejoin="round"
|
|
61
|
+
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
|
62
|
+
/>
|
|
63
|
+
</svg>
|
|
64
|
+
</button>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
class="rounded p-1 text-neutral-500 hover:bg-red-500/10 hover:text-red-400"
|
|
68
|
+
@click="$emit('delete', category)"
|
|
69
|
+
>
|
|
70
|
+
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
|
71
|
+
<path
|
|
72
|
+
stroke-linecap="round"
|
|
73
|
+
stroke-linejoin="round"
|
|
74
|
+
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
|
75
|
+
/>
|
|
76
|
+
</svg>
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<CategoryTree
|
|
81
|
+
:categories="categories"
|
|
82
|
+
:level="level + 1"
|
|
83
|
+
:parent-id="category.id"
|
|
84
|
+
@edit="$emit('edit', $event)"
|
|
85
|
+
@delete="$emit('delete', $event)"
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</template>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
title: { type: String, default: '' },
|
|
6
|
+
data: { type: Array, default: () => [] },
|
|
7
|
+
color: { type: String, default: 'cyan' },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const colorMap = {
|
|
11
|
+
cyan: 'bg-cyan-500',
|
|
12
|
+
violet: 'bg-violet-500',
|
|
13
|
+
emerald: 'bg-emerald-500',
|
|
14
|
+
amber: 'bg-amber-500',
|
|
15
|
+
red: 'bg-red-500',
|
|
16
|
+
blue: 'bg-blue-500',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const barColor = computed(() => colorMap[props.color] || colorMap.cyan);
|
|
20
|
+
|
|
21
|
+
const maxValue = computed(() => {
|
|
22
|
+
if (!props.data.length) return 1;
|
|
23
|
+
return Math.max(...props.data.map((d) => d.value), 1);
|
|
24
|
+
});
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<div class="rounded-xl border border-white/[0.06] bg-neutral-900/60 p-5">
|
|
29
|
+
<h3 v-if="title" class="mb-4 text-sm font-semibold text-neutral-200">{{ title }}</h3>
|
|
30
|
+
<div v-if="data.length" class="space-y-2.5">
|
|
31
|
+
<div v-for="item in data" :key="item.label" class="flex items-center gap-3">
|
|
32
|
+
<span class="w-20 shrink-0 truncate text-xs text-neutral-500">{{ item.label }}</span>
|
|
33
|
+
<div class="flex-1">
|
|
34
|
+
<div class="h-5 overflow-hidden rounded-full bg-white/[0.04]">
|
|
35
|
+
<div
|
|
36
|
+
:class="['h-full rounded-full transition-all', barColor]"
|
|
37
|
+
:style="{ width: `${(item.value / maxValue) * 100}%`, opacity: 0.8 }"
|
|
38
|
+
></div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<span class="w-10 text-right text-xs font-medium text-neutral-300">{{ item.value }}</span>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
<div v-else class="py-6 text-center text-sm text-neutral-500">No data available</div>
|
|
45
|
+
</div>
|
|
46
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import CollisionWarning from './CollisionWarning.vue';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
title: 'Components/CollisionWarning',
|
|
5
|
+
component: CollisionWarning,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const SingleTyper = {
|
|
9
|
+
args: {
|
|
10
|
+
typers: [{ name: 'Alice Chen' }],
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const TwoTypers = {
|
|
15
|
+
args: {
|
|
16
|
+
typers: [{ name: 'Alice Chen' }, { name: 'Bob Smith' }],
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const ManyTypers = {
|
|
21
|
+
args: {
|
|
22
|
+
typers: [{ name: 'Alice Chen' }, { name: 'Bob Smith' }, { name: 'Carol Davis' }, { name: 'Dan Lee' }],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const NoTypers = {
|
|
27
|
+
args: {
|
|
28
|
+
typers: [],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { inject, computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
defineProps({
|
|
5
|
+
typers: { type: Array, default: () => [] },
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const escDark = inject(
|
|
9
|
+
'esc-dark',
|
|
10
|
+
computed(() => false),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
function typersText(typers) {
|
|
14
|
+
const names = typers.map((t) => t.name);
|
|
15
|
+
if (names.length === 1) return `${names[0]} is typing a reply...`;
|
|
16
|
+
if (names.length === 2) return `${names[0]} and ${names[1]} are typing replies...`;
|
|
17
|
+
return `${names[0]} and ${names.length - 1} others are typing replies...`;
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<Transition
|
|
23
|
+
enter-active-class="transition duration-200 ease-out"
|
|
24
|
+
enter-from-class="opacity-0 -translate-y-2"
|
|
25
|
+
enter-to-class="opacity-100 translate-y-0"
|
|
26
|
+
leave-active-class="transition duration-150 ease-in"
|
|
27
|
+
leave-from-class="opacity-100 translate-y-0"
|
|
28
|
+
leave-to-class="opacity-0 -translate-y-2"
|
|
29
|
+
>
|
|
30
|
+
<div
|
|
31
|
+
v-if="typers.length > 0"
|
|
32
|
+
role="alert"
|
|
33
|
+
aria-live="assertive"
|
|
34
|
+
:class="[
|
|
35
|
+
'flex items-center gap-2 rounded-lg px-3 py-2 ring-1',
|
|
36
|
+
escDark ? 'bg-amber-500/10 ring-amber-500/20' : 'bg-amber-50 ring-amber-200',
|
|
37
|
+
]"
|
|
38
|
+
>
|
|
39
|
+
<!-- Pulsing indicator -->
|
|
40
|
+
<span class="relative flex h-2.5 w-2.5">
|
|
41
|
+
<span
|
|
42
|
+
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-75"
|
|
43
|
+
></span>
|
|
44
|
+
<span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-amber-500"></span>
|
|
45
|
+
</span>
|
|
46
|
+
|
|
47
|
+
<!-- Avatars -->
|
|
48
|
+
<div class="flex -space-x-1.5">
|
|
49
|
+
<div
|
|
50
|
+
v-for="typer in typers.slice(0, 3)"
|
|
51
|
+
:key="typer.name"
|
|
52
|
+
:class="[
|
|
53
|
+
'flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-semibold ring-1',
|
|
54
|
+
escDark
|
|
55
|
+
? 'bg-amber-500/30 text-amber-300 ring-amber-500/40'
|
|
56
|
+
: 'bg-amber-100 text-amber-700 ring-amber-300',
|
|
57
|
+
]"
|
|
58
|
+
>
|
|
59
|
+
{{ (typer.name || '?').charAt(0).toUpperCase() }}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<span :class="['text-xs font-medium', escDark ? 'text-amber-400' : 'text-amber-700']">
|
|
64
|
+
{{ typersText(typers) }}
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
</Transition>
|
|
68
|
+
</template>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
availableFields: { type: Array, default: () => [] },
|
|
6
|
+
modelValue: { type: Array, default: () => [] },
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const emit = defineEmits(['update:modelValue']);
|
|
10
|
+
|
|
11
|
+
const rules = computed({
|
|
12
|
+
get: () => props.modelValue,
|
|
13
|
+
set: (val) => emit('update:modelValue', val),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function addRule() {
|
|
17
|
+
const updated = [
|
|
18
|
+
...rules.value,
|
|
19
|
+
{
|
|
20
|
+
field: '',
|
|
21
|
+
operator: 'equals',
|
|
22
|
+
value: '',
|
|
23
|
+
target_field: '',
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
emit('update:modelValue', updated);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function removeRule(index) {
|
|
30
|
+
const updated = rules.value.filter((_, i) => i !== index);
|
|
31
|
+
emit('update:modelValue', updated);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function updateRule(index, key, value) {
|
|
35
|
+
const updated = rules.value.map((rule, i) => (i === index ? { ...rule, [key]: value } : rule));
|
|
36
|
+
emit('update:modelValue', updated);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getFieldOptions(fieldName) {
|
|
40
|
+
const field = props.availableFields.find((f) => f.name === fieldName || f.slug === fieldName);
|
|
41
|
+
return field?.options || [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const operatorOptions = [
|
|
45
|
+
{ value: 'equals', label: 'equals' },
|
|
46
|
+
{ value: 'not_equals', label: 'does not equal' },
|
|
47
|
+
{ value: 'contains', label: 'contains' },
|
|
48
|
+
{ value: 'is_empty', label: 'is empty' },
|
|
49
|
+
{ value: 'is_not_empty', label: 'is not empty' },
|
|
50
|
+
];
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<div class="space-y-3">
|
|
55
|
+
<div
|
|
56
|
+
v-for="(rule, index) in rules"
|
|
57
|
+
:key="index"
|
|
58
|
+
class="flex flex-wrap items-center gap-2 rounded-lg border border-white/[0.06] bg-neutral-950 p-3"
|
|
59
|
+
>
|
|
60
|
+
<span class="text-xs font-medium text-neutral-500">When</span>
|
|
61
|
+
<select
|
|
62
|
+
:value="rule.field"
|
|
63
|
+
class="rounded-lg border border-white/10 bg-neutral-900 px-2 py-1.5 text-sm text-neutral-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
|
64
|
+
@change="updateRule(index, 'field', $event.target.value)"
|
|
65
|
+
>
|
|
66
|
+
<option value="">Select field...</option>
|
|
67
|
+
<option v-for="f in availableFields" :key="f.name || f.slug" :value="f.name || f.slug">
|
|
68
|
+
{{ f.label || f.name }}
|
|
69
|
+
</option>
|
|
70
|
+
</select>
|
|
71
|
+
|
|
72
|
+
<select
|
|
73
|
+
:value="rule.operator"
|
|
74
|
+
class="rounded-lg border border-white/10 bg-neutral-900 px-2 py-1.5 text-sm text-neutral-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
|
75
|
+
@change="updateRule(index, 'operator', $event.target.value)"
|
|
76
|
+
>
|
|
77
|
+
<option v-for="op in operatorOptions" :key="op.value" :value="op.value">
|
|
78
|
+
{{ op.label }}
|
|
79
|
+
</option>
|
|
80
|
+
</select>
|
|
81
|
+
|
|
82
|
+
<template v-if="rule.operator !== 'is_empty' && rule.operator !== 'is_not_empty'">
|
|
83
|
+
<select
|
|
84
|
+
v-if="getFieldOptions(rule.field).length"
|
|
85
|
+
:value="rule.value"
|
|
86
|
+
class="rounded-lg border border-white/10 bg-neutral-900 px-2 py-1.5 text-sm text-neutral-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
|
87
|
+
@change="updateRule(index, 'value', $event.target.value)"
|
|
88
|
+
>
|
|
89
|
+
<option value="">Select value...</option>
|
|
90
|
+
<option v-for="opt in getFieldOptions(rule.field)" :key="opt" :value="opt">
|
|
91
|
+
{{ opt }}
|
|
92
|
+
</option>
|
|
93
|
+
</select>
|
|
94
|
+
<input
|
|
95
|
+
v-else
|
|
96
|
+
:value="rule.value"
|
|
97
|
+
type="text"
|
|
98
|
+
placeholder="Value"
|
|
99
|
+
class="w-32 rounded-lg border border-white/10 bg-neutral-900 px-2 py-1.5 text-sm text-neutral-200 placeholder-neutral-600 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
|
100
|
+
@input="updateRule(index, 'value', $event.target.value)"
|
|
101
|
+
/>
|
|
102
|
+
</template>
|
|
103
|
+
|
|
104
|
+
<span class="text-xs font-medium text-neutral-500">show</span>
|
|
105
|
+
<select
|
|
106
|
+
:value="rule.target_field"
|
|
107
|
+
class="rounded-lg border border-white/10 bg-neutral-900 px-2 py-1.5 text-sm text-neutral-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
|
108
|
+
@change="updateRule(index, 'target_field', $event.target.value)"
|
|
109
|
+
>
|
|
110
|
+
<option value="">Select target field...</option>
|
|
111
|
+
<option v-for="f in availableFields" :key="f.name || f.slug" :value="f.name || f.slug">
|
|
112
|
+
{{ f.label || f.name }}
|
|
113
|
+
</option>
|
|
114
|
+
</select>
|
|
115
|
+
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
class="ml-auto rounded p-1 text-neutral-500 hover:bg-red-500/10 hover:text-red-400"
|
|
119
|
+
@click="removeRule(index)"
|
|
120
|
+
>
|
|
121
|
+
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
|
122
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
123
|
+
</svg>
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
class="rounded-lg border border-dashed border-white/10 px-4 py-2 text-sm text-neutral-500 hover:border-white/20 hover:text-neutral-300"
|
|
130
|
+
@click="addRule"
|
|
131
|
+
>
|
|
132
|
+
+ Add Condition
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
</template>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { inject, computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
defineProps({
|
|
5
|
+
visible: { type: Boolean, default: false },
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const emit = defineEmits(['update:visible']);
|
|
9
|
+
|
|
10
|
+
const escDark = inject(
|
|
11
|
+
'esc-dark',
|
|
12
|
+
computed(() => false),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
function close() {
|
|
16
|
+
emit('update:visible', false);
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<transition
|
|
22
|
+
enter-active-class="transition-all duration-300 ease-out"
|
|
23
|
+
leave-active-class="transition-all duration-200 ease-in"
|
|
24
|
+
enter-from-class="translate-x-full opacity-0"
|
|
25
|
+
enter-to-class="translate-x-0 opacity-100"
|
|
26
|
+
leave-from-class="translate-x-0 opacity-100"
|
|
27
|
+
leave-to-class="translate-x-full opacity-0"
|
|
28
|
+
>
|
|
29
|
+
<div
|
|
30
|
+
v-if="visible"
|
|
31
|
+
:class="[
|
|
32
|
+
'fixed inset-y-0 right-0 z-40 w-80 overflow-y-auto border-l shadow-2xl backdrop-blur-sm lg:static lg:block lg:overflow-visible lg:shadow-none',
|
|
33
|
+
escDark ? 'border-white/[0.06] bg-neutral-900/95' : 'border-gray-200 bg-white',
|
|
34
|
+
]"
|
|
35
|
+
>
|
|
36
|
+
<!-- Header -->
|
|
37
|
+
<div
|
|
38
|
+
:class="[
|
|
39
|
+
'flex items-center justify-between border-b px-4 py-3',
|
|
40
|
+
escDark ? 'border-white/[0.06]' : 'border-gray-200',
|
|
41
|
+
]"
|
|
42
|
+
>
|
|
43
|
+
<h3 :class="['text-sm font-semibold', escDark ? 'text-neutral-200' : 'text-gray-900']">Context</h3>
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
:class="[
|
|
47
|
+
'rounded p-1 lg:hidden',
|
|
48
|
+
escDark
|
|
49
|
+
? 'text-neutral-500 hover:bg-white/[0.06] hover:text-neutral-300'
|
|
50
|
+
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-600',
|
|
51
|
+
]"
|
|
52
|
+
@click="close"
|
|
53
|
+
>
|
|
54
|
+
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
|
55
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
56
|
+
</svg>
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- Sections -->
|
|
61
|
+
<div>
|
|
62
|
+
<slot />
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</transition>
|
|
66
|
+
</template>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, inject, computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
title: { type: String, required: true },
|
|
6
|
+
icon: { type: String, default: '' },
|
|
7
|
+
defaultOpen: { type: Boolean, default: true },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const escDark = inject(
|
|
11
|
+
'esc-dark',
|
|
12
|
+
computed(() => false),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const isOpen = ref(props.defaultOpen);
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<div :class="['border-b last:border-b-0', escDark ? 'border-white/[0.06]' : 'border-gray-200']">
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
:aria-expanded="isOpen"
|
|
23
|
+
:class="[
|
|
24
|
+
'flex w-full items-center gap-2 px-4 py-3 text-left transition-colors',
|
|
25
|
+
escDark ? 'hover:bg-white/[0.02]' : 'hover:bg-gray-50',
|
|
26
|
+
]"
|
|
27
|
+
@click="isOpen = !isOpen"
|
|
28
|
+
>
|
|
29
|
+
<svg
|
|
30
|
+
v-if="icon"
|
|
31
|
+
:class="['h-4 w-4 shrink-0', escDark ? 'text-neutral-500' : 'text-gray-400']"
|
|
32
|
+
fill="none"
|
|
33
|
+
stroke="currentColor"
|
|
34
|
+
stroke-width="1.5"
|
|
35
|
+
viewBox="0 0 24 24"
|
|
36
|
+
>
|
|
37
|
+
<path stroke-linecap="round" stroke-linejoin="round" :d="icon" />
|
|
38
|
+
</svg>
|
|
39
|
+
<span
|
|
40
|
+
:class="[
|
|
41
|
+
'flex-1 text-xs font-semibold uppercase tracking-wider',
|
|
42
|
+
escDark ? 'text-neutral-400' : 'text-gray-500',
|
|
43
|
+
]"
|
|
44
|
+
>
|
|
45
|
+
{{ title }}
|
|
46
|
+
</span>
|
|
47
|
+
<svg
|
|
48
|
+
:class="[
|
|
49
|
+
'h-4 w-4 transition-transform',
|
|
50
|
+
escDark ? 'text-neutral-600' : 'text-gray-400',
|
|
51
|
+
isOpen ? 'rotate-180' : '',
|
|
52
|
+
]"
|
|
53
|
+
fill="none"
|
|
54
|
+
stroke="currentColor"
|
|
55
|
+
stroke-width="1.5"
|
|
56
|
+
viewBox="0 0 24 24"
|
|
57
|
+
>
|
|
58
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
59
|
+
</svg>
|
|
60
|
+
</button>
|
|
61
|
+
<div v-show="isOpen" class="px-4 pb-4">
|
|
62
|
+
<slot />
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
fields: { type: Array, required: true },
|
|
6
|
+
modelValue: { type: Object, default: () => ({}) },
|
|
7
|
+
errors: { type: Object, default: () => ({}) },
|
|
8
|
+
conditions: { type: Array, default: () => [] },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits(['update:modelValue']);
|
|
12
|
+
|
|
13
|
+
function updateField(name, value) {
|
|
14
|
+
emit('update:modelValue', { ...props.modelValue, [name]: value });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toggleMultiSelect(name, option) {
|
|
18
|
+
const current = props.modelValue[name] || [];
|
|
19
|
+
const idx = current.indexOf(option);
|
|
20
|
+
const updated = idx >= 0 ? current.filter((_, i) => i !== idx) : [...current, option];
|
|
21
|
+
updateField(name, updated);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const sortedFields = computed(() => [...props.fields].sort((a, b) => (a.position || 0) - (b.position || 0)));
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Evaluate whether a field should be visible based on conditions.
|
|
28
|
+
* Conditions can come from the `conditions` prop or from the field's own `conditions` property.
|
|
29
|
+
*/
|
|
30
|
+
function isFieldVisible(field) {
|
|
31
|
+
// Merge global conditions and field-level conditions
|
|
32
|
+
const allConditions = [
|
|
33
|
+
...props.conditions.filter((c) => c.target_field === field.name || c.target_field === field.slug),
|
|
34
|
+
...(field.conditions || []).filter((c) => c.target_field === field.name || c.target_field === field.slug),
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// If no conditions target this field, it's always visible
|
|
38
|
+
if (allConditions.length === 0) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// All conditions must be met (AND logic)
|
|
43
|
+
return allConditions.every((condition) => {
|
|
44
|
+
const currentValue = props.modelValue[condition.field];
|
|
45
|
+
|
|
46
|
+
switch (condition.operator) {
|
|
47
|
+
case 'equals':
|
|
48
|
+
return String(currentValue || '') === String(condition.value || '');
|
|
49
|
+
case 'not_equals':
|
|
50
|
+
return String(currentValue || '') !== String(condition.value || '');
|
|
51
|
+
case 'contains':
|
|
52
|
+
return String(currentValue || '').includes(String(condition.value || ''));
|
|
53
|
+
case 'is_empty':
|
|
54
|
+
return (
|
|
55
|
+
!currentValue || currentValue === '' || (Array.isArray(currentValue) && currentValue.length === 0)
|
|
56
|
+
);
|
|
57
|
+
case 'is_not_empty':
|
|
58
|
+
return (
|
|
59
|
+
currentValue && currentValue !== '' && !(Array.isArray(currentValue) && currentValue.length === 0)
|
|
60
|
+
);
|
|
61
|
+
default:
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<div class="space-y-4">
|
|
70
|
+
<div v-for="field in sortedFields" v-show="isFieldVisible(field)" :key="field.id">
|
|
71
|
+
<label class="block text-sm font-medium text-neutral-300">
|
|
72
|
+
{{ field.label || field.name }}
|
|
73
|
+
<span v-if="field.required" class="text-rose-400">*</span>
|
|
74
|
+
</label>
|
|
75
|
+
<p v-if="field.description" class="mt-0.5 text-xs text-neutral-500">{{ field.description }}</p>
|
|
76
|
+
|
|
77
|
+
<!-- Text -->
|
|
78
|
+
<input
|
|
79
|
+
v-if="field.type === 'text'"
|
|
80
|
+
type="text"
|
|
81
|
+
:value="modelValue[field.name] || ''"
|
|
82
|
+
:placeholder="field.placeholder"
|
|
83
|
+
:required="field.required"
|
|
84
|
+
class="mt-1 w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 placeholder-neutral-600 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
|
85
|
+
@input="updateField(field.name, $event.target.value)"
|
|
86
|
+
/>
|
|
87
|
+
|
|
88
|
+
<!-- Textarea -->
|
|
89
|
+
<textarea
|
|
90
|
+
v-else-if="field.type === 'textarea'"
|
|
91
|
+
:value="modelValue[field.name] || ''"
|
|
92
|
+
:placeholder="field.placeholder"
|
|
93
|
+
:required="field.required"
|
|
94
|
+
rows="3"
|
|
95
|
+
class="mt-1 w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 placeholder-neutral-600 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
|
96
|
+
@input="updateField(field.name, $event.target.value)"
|
|
97
|
+
></textarea>
|
|
98
|
+
|
|
99
|
+
<!-- Number -->
|
|
100
|
+
<input
|
|
101
|
+
v-else-if="field.type === 'number'"
|
|
102
|
+
type="number"
|
|
103
|
+
:value="modelValue[field.name] || ''"
|
|
104
|
+
:placeholder="field.placeholder"
|
|
105
|
+
:required="field.required"
|
|
106
|
+
class="mt-1 w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 placeholder-neutral-600 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
|
107
|
+
@input="updateField(field.name, $event.target.value)"
|
|
108
|
+
/>
|
|
109
|
+
|
|
110
|
+
<!-- Date -->
|
|
111
|
+
<input
|
|
112
|
+
v-else-if="field.type === 'date'"
|
|
113
|
+
type="date"
|
|
114
|
+
:value="modelValue[field.name] || ''"
|
|
115
|
+
:required="field.required"
|
|
116
|
+
class="mt-1 w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
|
117
|
+
@input="updateField(field.name, $event.target.value)"
|
|
118
|
+
/>
|
|
119
|
+
|
|
120
|
+
<!-- Checkbox -->
|
|
121
|
+
<label v-else-if="field.type === 'checkbox'" class="mt-1 flex items-center gap-2">
|
|
122
|
+
<input
|
|
123
|
+
type="checkbox"
|
|
124
|
+
:checked="!!modelValue[field.name]"
|
|
125
|
+
class="rounded border-white/20 bg-neutral-900 text-cyan-500 focus:ring-white/10"
|
|
126
|
+
@change="updateField(field.name, $event.target.checked)"
|
|
127
|
+
/>
|
|
128
|
+
<span class="text-sm text-neutral-300">{{ field.placeholder || 'Yes' }}</span>
|
|
129
|
+
</label>
|
|
130
|
+
|
|
131
|
+
<!-- Select -->
|
|
132
|
+
<select
|
|
133
|
+
v-else-if="field.type === 'select'"
|
|
134
|
+
:value="modelValue[field.name] || ''"
|
|
135
|
+
:required="field.required"
|
|
136
|
+
class="mt-1 w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
|
137
|
+
@change="updateField(field.name, $event.target.value)"
|
|
138
|
+
>
|
|
139
|
+
<option value="" disabled class="text-neutral-500">{{ field.placeholder || 'Select...' }}</option>
|
|
140
|
+
<option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
|
|
141
|
+
</select>
|
|
142
|
+
|
|
143
|
+
<!-- Multi-select (checkboxes) -->
|
|
144
|
+
<div v-else-if="field.type === 'multi_select'" class="mt-1 space-y-1">
|
|
145
|
+
<label v-for="opt in field.options" :key="opt" class="flex items-center gap-2">
|
|
146
|
+
<input
|
|
147
|
+
type="checkbox"
|
|
148
|
+
:checked="(modelValue[field.name] || []).includes(opt)"
|
|
149
|
+
class="rounded border-white/20 bg-neutral-900 text-cyan-500 focus:ring-white/10"
|
|
150
|
+
@change="toggleMultiSelect(field.name, opt)"
|
|
151
|
+
/>
|
|
152
|
+
<span class="text-sm text-neutral-300">{{ opt }}</span>
|
|
153
|
+
</label>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div v-if="errors[field.name]" class="mt-1 text-sm text-rose-400">{{ errors[field.name] }}</div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</template>
|