@antelopejs/dms-saas 0.3.1 → 0.3.3

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.
Files changed (115) hide show
  1. package/README.md +0 -86
  2. package/dist/config/runtime.d.ts +0 -1
  3. package/dist/config/runtime.js +0 -7
  4. package/dist/config/runtime.js.map +1 -1
  5. package/dist/data-api/platformOwner/index.d.ts +0 -1
  6. package/dist/data-api/platformOwner/index.js +0 -1
  7. package/dist/data-api/platformOwner/index.js.map +1 -1
  8. package/dist/data-api/tenant/index.d.ts +0 -1
  9. package/dist/data-api/tenant/index.js +0 -1
  10. package/dist/data-api/tenant/index.js.map +1 -1
  11. package/dist/notifications/subjects.d.ts +0 -2
  12. package/dist/notifications/subjects.js +1 -13
  13. package/dist/notifications/subjects.js.map +1 -1
  14. package/dist/pages/platform/categories.d.ts +0 -1
  15. package/dist/pages/platform/categories.js +1 -8
  16. package/dist/pages/platform/categories.js.map +1 -1
  17. package/dist/pages/platform/index.d.ts +0 -1
  18. package/dist/pages/platform/index.js +0 -1
  19. package/dist/pages/platform/index.js.map +1 -1
  20. package/dist/pages/tenant/index.d.ts +0 -1
  21. package/dist/pages/tenant/index.js +0 -1
  22. package/dist/pages/tenant/index.js.map +1 -1
  23. package/dist/routes/platformOwner/index.d.ts +0 -1
  24. package/dist/routes/platformOwner/index.js +0 -1
  25. package/dist/routes/platformOwner/index.js.map +1 -1
  26. package/dist/routes/public/register.js +1 -3
  27. package/dist/routes/public/register.js.map +1 -1
  28. package/dist/routes/tenant/index.d.ts +0 -1
  29. package/dist/routes/tenant/index.js +0 -1
  30. package/dist/routes/tenant/index.js.map +1 -1
  31. package/dist/routes/tenant/tenant-billing.d.ts +2 -1
  32. package/dist/routes/tenant/tenant-billing.js +14 -11
  33. package/dist/routes/tenant/tenant-billing.js.map +1 -1
  34. package/dist/stripe/webhook-handlers.js +19 -20
  35. package/dist/stripe/webhook-handlers.js.map +1 -1
  36. package/dist/stripe/webhook-shared.js +9 -11
  37. package/dist/stripe/webhook-shared.js.map +1 -1
  38. package/dist/types/config.d.ts +0 -1
  39. package/dist/utils/index.d.ts +1 -0
  40. package/dist/utils/index.js +1 -0
  41. package/dist/utils/index.js.map +1 -1
  42. package/dist/utils/keyed-insert.d.ts +7 -0
  43. package/dist/utils/keyed-insert.js +14 -0
  44. package/dist/utils/keyed-insert.js.map +1 -0
  45. package/dist/workspaces/provisioning.js +22 -27
  46. package/dist/workspaces/provisioning.js.map +1 -1
  47. package/frontend-vue/i18n/locales/saas-en-GB.json +1 -142
  48. package/frontend-vue/i18n/locales/saas-fr-FR.json +1 -142
  49. package/package.json +7 -9
  50. package/dist/data-api/platformOwner/support.d.ts +0 -58
  51. package/dist/data-api/platformOwner/support.js +0 -111
  52. package/dist/data-api/platformOwner/support.js.map +0 -1
  53. package/dist/data-api/support-fields.d.ts +0 -49
  54. package/dist/data-api/support-fields.js +0 -166
  55. package/dist/data-api/support-fields.js.map +0 -1
  56. package/dist/data-api/support-routes.d.ts +0 -10
  57. package/dist/data-api/support-routes.js +0 -71
  58. package/dist/data-api/support-routes.js.map +0 -1
  59. package/dist/data-api/tenant/support.d.ts +0 -39
  60. package/dist/data-api/tenant/support.js +0 -53
  61. package/dist/data-api/tenant/support.js.map +0 -1
  62. package/dist/pages/platform/support.d.ts +0 -9
  63. package/dist/pages/platform/support.js +0 -29
  64. package/dist/pages/platform/support.js.map +0 -1
  65. package/dist/pages/tenant/support.d.ts +0 -9
  66. package/dist/pages/tenant/support.js +0 -27
  67. package/dist/pages/tenant/support.js.map +0 -1
  68. package/dist/routes/platformOwner/support.d.ts +0 -21
  69. package/dist/routes/platformOwner/support.js +0 -180
  70. package/dist/routes/platformOwner/support.js.map +0 -1
  71. package/dist/routes/tenant/support.d.ts +0 -27
  72. package/dist/routes/tenant/support.js +0 -185
  73. package/dist/routes/tenant/support.js.map +0 -1
  74. package/dist/support/admissions.d.ts +0 -36
  75. package/dist/support/admissions.js +0 -136
  76. package/dist/support/admissions.js.map +0 -1
  77. package/dist/support/attachments.d.ts +0 -12
  78. package/dist/support/attachments.js +0 -56
  79. package/dist/support/attachments.js.map +0 -1
  80. package/dist/support/config.d.ts +0 -19
  81. package/dist/support/config.js +0 -75
  82. package/dist/support/config.js.map +0 -1
  83. package/dist/support/index.d.ts +0 -6
  84. package/dist/support/index.js +0 -23
  85. package/dist/support/index.js.map +0 -1
  86. package/dist/support/operations.d.ts +0 -51
  87. package/dist/support/operations.js +0 -109
  88. package/dist/support/operations.js.map +0 -1
  89. package/dist/support/plans.d.ts +0 -6
  90. package/dist/support/plans.js +0 -130
  91. package/dist/support/plans.js.map +0 -1
  92. package/dist/support/publications.d.ts +0 -8
  93. package/dist/support/publications.js +0 -106
  94. package/dist/support/publications.js.map +0 -1
  95. package/dist/support/recovery.d.ts +0 -7
  96. package/dist/support/recovery.js +0 -43
  97. package/dist/support/recovery.js.map +0 -1
  98. package/dist/support/service.d.ts +0 -51
  99. package/dist/support/service.js +0 -143
  100. package/dist/support/service.js.map +0 -1
  101. package/dist/support/transitions.d.ts +0 -4
  102. package/dist/support/transitions.js +0 -29
  103. package/dist/support/transitions.js.map +0 -1
  104. package/dist/support/uploads.d.ts +0 -39
  105. package/dist/support/uploads.js +0 -115
  106. package/dist/support/uploads.js.map +0 -1
  107. package/dist/support/validation.d.ts +0 -15
  108. package/dist/support/validation.js +0 -54
  109. package/dist/support/validation.js.map +0 -1
  110. package/frontend-vue/app/components/PlatformSupport.vue +0 -461
  111. package/frontend-vue/app/components/SupportAttachmentUpload.vue +0 -140
  112. package/frontend-vue/app/components/SupportThreadHistory.vue +0 -139
  113. package/frontend-vue/app/components/TenantSupport.vue +0 -415
  114. package/frontend-vue/app/composables/useSupportRequest.ts +0 -74
  115. package/frontend-vue/app/types/support.ts +0 -123
@@ -1,140 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed, ref, watch } from "vue";
3
-
4
- interface AttachmentConstraints {
5
- maxSize?: number;
6
- allowedMimetypes?: string[];
7
- }
8
- interface UploadProps {
9
- modelValue: string[];
10
- multiple?: boolean;
11
- constraints?: AttachmentConstraints;
12
- disabled?: boolean;
13
- }
14
- interface SupportPresign {
15
- resourceKey: string;
16
- uploadUrl: string;
17
- headers: Record<string, string>;
18
- }
19
- const props = defineProps<UploadProps>();
20
- const emit = defineEmits<{
21
- "update:modelValue": [string[]];
22
- busy: [boolean];
23
- }>();
24
- const { $authFetch } = useAuthFetch();
25
- const { uploadWithProgress } = useUploadWithProgress();
26
- const { t } = useI18n();
27
- const selected = ref<File | File[] | null>(null);
28
- const names = ref(new Map<string, string>());
29
- const isUploading = ref(false);
30
- const progress = ref(0);
31
- const currentName = ref("");
32
- const error = ref("");
33
- const accept = computed(() => props.constraints?.allowedMimetypes?.join(","));
34
-
35
- function validate(file: File): boolean {
36
- const { maxSize, allowedMimetypes } = props.constraints ?? {};
37
- if (maxSize !== undefined && file.size > maxSize) {
38
- error.value = t("dms.form.file.rejected_size", {
39
- name: file.name,
40
- size: `${maxSize} B`,
41
- });
42
- return false;
43
- }
44
- if (
45
- allowedMimetypes?.length &&
46
- !allowedMimetypes.some((mime) =>
47
- mime.endsWith("/*")
48
- ? file.type.startsWith(mime.slice(0, -1))
49
- : mime === file.type,
50
- )
51
- ) {
52
- error.value = t("dms.form.file.rejected_type", { name: file.name });
53
- return false;
54
- }
55
- return true;
56
- }
57
-
58
- async function upload(file: File): Promise<void> {
59
- if (!validate(file)) return;
60
- currentName.value = file.name;
61
- progress.value = 0;
62
- const presign = await $authFetch<SupportPresign>(
63
- "/api/saas/support/uploads",
64
- {
65
- method: "POST",
66
- body: { filename: file.name, size: file.size, mimetype: file.type },
67
- },
68
- );
69
- await uploadWithProgress(presign, file, (value: number) => {
70
- progress.value = value;
71
- });
72
- names.value.set(presign.resourceKey, file.name);
73
- emit(
74
- "update:modelValue",
75
- props.multiple
76
- ? [...props.modelValue, presign.resourceKey]
77
- : [presign.resourceKey],
78
- );
79
- }
80
-
81
- watch(selected, async (value) => {
82
- if (!value || props.disabled || isUploading.value) return;
83
- isUploading.value = true;
84
- emit("busy", true);
85
- error.value = "";
86
- try {
87
- const files = Array.isArray(value) ? value : [value];
88
- for (const file of props.multiple ? files : files.slice(0, 1))
89
- await upload(file);
90
- } catch {
91
- error.value = t("saas.support.error.upload");
92
- } finally {
93
- selected.value = null;
94
- isUploading.value = false;
95
- emit("busy", false);
96
- }
97
- });
98
-
99
- function remove(key: string): void {
100
- if (props.disabled || isUploading.value) return;
101
- emit(
102
- "update:modelValue",
103
- props.modelValue.filter((value) => value !== key),
104
- );
105
- names.value.delete(key);
106
- }
107
- </script>
108
-
109
- <template>
110
- <div class="flex flex-col gap-2">
111
- <UFileUpload
112
- v-model="selected"
113
- :multiple="multiple"
114
- :accept="accept"
115
- :disabled="disabled || isUploading"
116
- />
117
- <div v-if="isUploading" role="status" class="bg-elevated rounded-md p-2">
118
- <span class="text-sm">{{ currentName }}</span>
119
- <UProgress :model-value="progress" />
120
- </div>
121
- <p v-if="error" role="alert" class="text-error text-sm">{{ error }}</p>
122
- <div
123
- v-for="key in modelValue"
124
- :key="key"
125
- class="bg-elevated flex items-center justify-between gap-2 rounded-md p-2"
126
- >
127
- <span class="min-w-0 truncate text-sm">
128
- {{ names.get(key) ?? $t("saas.support.attachments") }}
129
- </span>
130
- <UButton
131
- icon="i-lucide-x"
132
- color="neutral"
133
- variant="ghost"
134
- :aria-label="$t('saas.support.remove_attachment')"
135
- :disabled="disabled || isUploading"
136
- @click="remove(key)"
137
- />
138
- </div>
139
- </div>
140
- </template>
@@ -1,139 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed } from "vue";
3
-
4
- interface Props {
5
- messages: SupportPageView<SupportMessageView>;
6
- events: SupportPageView<SupportTicketEventView>;
7
- assigneeLabels?: Record<string, string>;
8
- }
9
-
10
- interface HistoryEmits {
11
- attachment: [resourceKey: string];
12
- messagesPage: [page: number];
13
- eventsPage: [page: number];
14
- }
15
-
16
- const props = withDefaults(defineProps<Props>(), {
17
- assigneeLabels: () => ({}),
18
- });
19
- const emit = defineEmits<HistoryEmits>();
20
- const { t } = useI18n();
21
-
22
- const chronologicalMessages = computed(() =>
23
- [...props.messages.results].reverse(),
24
- );
25
- const messagesPage = computed(
26
- () => Math.floor(props.messages.offset / props.messages.limit) + 1,
27
- );
28
- const eventsPage = computed(
29
- () => Math.floor(props.events.offset / props.events.limit) + 1,
30
- );
31
-
32
- function formatDate(value: string): string {
33
- return new Intl.DateTimeFormat(undefined, {
34
- dateStyle: "medium",
35
- timeStyle: "short",
36
- }).format(new Date(value));
37
- }
38
-
39
- function eventValue(
40
- event: SupportTicketEventView,
41
- value: string | null,
42
- ): string {
43
- if (!value) return t("saas.support.platform.unassigned");
44
- if (event.type === "status_changed")
45
- return t(`saas.support.status.${value}`);
46
- return props.assigneeLabels[value] ?? value;
47
- }
48
- </script>
49
-
50
- <template>
51
- <section class="flex flex-col gap-4">
52
- <div class="flex items-center justify-between gap-3">
53
- <h3 class="font-medium">{{ $t("saas.support.conversation") }}</h3>
54
- <span v-if="messagesPage === 1" class="text-muted text-xs">
55
- {{ $t("saas.support.pagination.latest_page") }}
56
- </span>
57
- </div>
58
- <article
59
- v-for="message in chronologicalMessages"
60
- :key="message._id"
61
- class="rounded-lg p-4"
62
- :class="
63
- message.authorType === 'platform' ? 'bg-primary/10' : 'bg-elevated'
64
- "
65
- >
66
- <div class="text-muted mb-2 flex justify-between text-xs">
67
- <span>{{ $t(`saas.support.author.${message.authorType}`) }}</span>
68
- <time>{{ formatDate(message.createdAt) }}</time>
69
- </div>
70
- <p class="whitespace-pre-wrap text-sm">{{ message.body }}</p>
71
- <div
72
- v-if="message.attachments.length"
73
- class="mt-3 flex flex-wrap gap-2"
74
- >
75
- <UButton
76
- v-for="(attachment, index) in message.attachments"
77
- :key="attachment"
78
- size="xs"
79
- color="neutral"
80
- variant="outline"
81
- icon="i-ph-paperclip"
82
- @click="emit('attachment', attachment)"
83
- >
84
- {{ $t("saas.support.attachment", { number: index + 1 }) }}
85
- </UButton>
86
- </div>
87
- </article>
88
- <div
89
- v-if="messages.total > messages.limit"
90
- class="flex flex-col items-center gap-1"
91
- >
92
- <span class="text-muted text-xs">
93
- {{ $t("saas.support.pagination.older_messages") }}
94
- </span>
95
- <UPagination
96
- :page="messagesPage"
97
- :total="messages.total"
98
- :items-per-page="messages.limit"
99
- size="sm"
100
- @update:page="emit('messagesPage', $event)"
101
- />
102
- </div>
103
- </section>
104
-
105
- <USeparator />
106
- <section class="flex flex-col gap-3">
107
- <h3 class="font-medium">{{ $t("saas.support.history.title") }}</h3>
108
- <p v-if="!events.results.length" class="text-muted text-sm">
109
- {{ $t("saas.support.history.empty") }}
110
- </p>
111
- <div
112
- v-for="event in events.results"
113
- :key="event._id"
114
- class="bg-elevated rounded-lg p-3 text-sm"
115
- >
116
- <p>
117
- {{
118
- $t(`saas.support.history.${event.type}`, {
119
- actor: event.actorName,
120
- previous: eventValue(event, event.previousValue),
121
- next: eventValue(event, event.newValue),
122
- })
123
- }}
124
- </p>
125
- <time class="text-muted text-xs">
126
- {{ formatDate(event.createdAt) }}
127
- </time>
128
- </div>
129
- <UPagination
130
- v-if="events.total > events.limit"
131
- :page="eventsPage"
132
- :total="events.total"
133
- :items-per-page="events.limit"
134
- size="sm"
135
- class="self-center"
136
- @update:page="emit('eventsPage', $event)"
137
- />
138
- </section>
139
- </template>
@@ -1,415 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed, onMounted, reactive, ref } from "vue";
3
- import { sendSupportRequest } from "../composables/useSupportRequest";
4
- import SupportAttachmentUpload from "./SupportAttachmentUpload.vue";
5
- import SupportThreadHistory from "./SupportThreadHistory.vue";
6
-
7
- const { $authFetch } = useAuthFetch();
8
- const { t } = useI18n();
9
- const toast = useToast();
10
- const { resolveApiError } = useApiErrorMessage();
11
-
12
- const tickets = ref<SupportTicketView[]>([]);
13
- const selected = ref<SupportThreadView | null>(null);
14
- const config = ref<SupportConfigView | null>(null);
15
- const isLoading = ref(true);
16
- const isSubmitting = ref(false);
17
- const isReplyUploading = ref(false);
18
- const isDraftUploading = ref(false);
19
- const isCreateOpen = ref(false);
20
- const replyBody = ref("");
21
- const replyAttachments = ref<string[]>([]);
22
- const PAGE_SIZE = 20;
23
- const listPage = ref(1);
24
- const messagesPage = ref(1);
25
- const eventsPage = ref(1);
26
- const ticketTotal = ref(0);
27
- let selectionRequest = 0;
28
- let messagesRequest = 0;
29
- let eventsRequest = 0;
30
- let listRequest = 0;
31
-
32
- const draft = reactive<SupportTicketDraft>({
33
- subject: "",
34
- category: "question",
35
- priority: "normal",
36
- body: "",
37
- attachments: [],
38
- });
39
-
40
- const categoryItems = [
41
- "question",
42
- "incident",
43
- "billing",
44
- "feature_request",
45
- ].map((value) => ({ value, label: t(`saas.support.category.${value}`) }));
46
- const priorityItems = computed(() =>
47
- (config.value?.policy.priorities ?? []).map((value) => ({
48
- value,
49
- label: t(`saas.support.priority.${value}`),
50
- })),
51
- );
52
- const attachmentConstraints = computed(() => ({
53
- maxSize: config.value?.maxAttachmentSize,
54
- allowedMimetypes: config.value?.allowedMimetypes,
55
- }));
56
-
57
- function formatDate(value: string): string {
58
- return new Intl.DateTimeFormat(undefined, {
59
- dateStyle: "medium",
60
- timeStyle: "short",
61
- }).format(new Date(value));
62
- }
63
-
64
- function resetDraft(): void {
65
- draft.subject = "";
66
- draft.category = "question";
67
- draft.priority = config.value?.policy.priorities[0] ?? "normal";
68
- draft.body = "";
69
- draft.attachments = [];
70
- }
71
-
72
- function pageQuery(page: number, sortKey: string): string {
73
- return new URLSearchParams({
74
- offset: String((page - 1) * PAGE_SIZE),
75
- limit: String(PAGE_SIZE),
76
- sortKey,
77
- sortDirection: "desc",
78
- }).toString();
79
- }
80
-
81
- async function load(): Promise<void> {
82
- const request = ++listRequest;
83
- isLoading.value = true;
84
- try {
85
- const [loadedConfig, loadedTickets] = await Promise.all([
86
- $authFetch<SupportConfigView>("/api/saas/support/config"),
87
- $authFetch<SupportPageView<SupportTicketView>>(
88
- `/api/saas/tenant/tables/support-tickets/list?${pageQuery(listPage.value, "lastMessageAt")}`,
89
- ),
90
- ]);
91
- if (request !== listRequest) return;
92
- config.value = loadedConfig;
93
- tickets.value = loadedTickets.results;
94
- ticketTotal.value = loadedTickets.total;
95
- if (!loadedConfig.policy.priorities.includes(draft.priority)) resetDraft();
96
- } catch (error) {
97
- showError(error, "saas.support.error.load");
98
- } finally {
99
- if (request === listRequest) isLoading.value = false;
100
- }
101
- }
102
-
103
- async function selectTicket(ticketId: string): Promise<void> {
104
- messagesPage.value = 1;
105
- eventsPage.value = 1;
106
- const request = ++selectionRequest;
107
- const messageRequest = ++messagesRequest;
108
- const eventRequest = ++eventsRequest;
109
- const baseUrl = `/api/saas/support/${ticketId}`;
110
- try {
111
- const [detail, messages, events] = await Promise.all([
112
- $authFetch<SupportTicketDetailView>(baseUrl),
113
- $authFetch<SupportPageView<SupportMessageView>>(
114
- `${baseUrl}/messages/list?${pageQuery(messagesPage.value, "createdAt")}`,
115
- ),
116
- $authFetch<SupportPageView<SupportTicketEventView>>(
117
- `${baseUrl}/events/list?${pageQuery(eventsPage.value, "createdAt")}`,
118
- ),
119
- ]);
120
- if (
121
- request === selectionRequest &&
122
- messageRequest === messagesRequest &&
123
- eventRequest === eventsRequest
124
- ) {
125
- selected.value = { ticket: detail.ticket, messages, events };
126
- }
127
- } catch (error) {
128
- showError(error, "saas.support.error.load");
129
- }
130
- }
131
-
132
- async function createTicket(): Promise<void> {
133
- if (!draft.subject.trim() || !draft.body.trim()) return;
134
- isSubmitting.value = true;
135
- try {
136
- const ticket = await sendSupportRequest(
137
- `${config.value?.uploadPath}:create`,
138
- draft,
139
- (body) =>
140
- $authFetch<SupportTicketView>("/api/saas/support", {
141
- method: "POST",
142
- body,
143
- }),
144
- );
145
- isCreateOpen.value = false;
146
- resetDraft();
147
- listPage.value = 1;
148
- await load();
149
- await selectTicket(ticket._id);
150
- toast.add({
151
- title: t("saas.support.tenant.created"),
152
- color: "success",
153
- });
154
- } catch (error) {
155
- showError(error, "saas.support.error.create");
156
- } finally {
157
- isSubmitting.value = false;
158
- }
159
- }
160
-
161
- async function sendReply(): Promise<void> {
162
- if (!selected.value || !replyBody.value.trim()) return;
163
- const ticketId = selected.value.ticket._id;
164
- isSubmitting.value = true;
165
- try {
166
- await sendSupportRequest(
167
- `${config.value?.uploadPath}:${ticketId}:reply`,
168
- { body: replyBody.value, attachments: replyAttachments.value },
169
- (body) =>
170
- $authFetch(`/api/saas/support/${ticketId}/messages`, {
171
- method: "POST",
172
- body,
173
- }),
174
- );
175
- replyBody.value = "";
176
- replyAttachments.value = [];
177
- if (selected.value?.ticket._id === ticketId) await selectTicket(ticketId);
178
- await load();
179
- } catch (error) {
180
- showError(error, "saas.support.error.reply");
181
- } finally {
182
- isSubmitting.value = false;
183
- }
184
- }
185
-
186
- async function openAttachment(
187
- ticketId: string,
188
- resourceKey: string,
189
- ): Promise<void> {
190
- try {
191
- const query = new URLSearchParams({ resourceKey });
192
- const file = await $authFetch<SupportAttachmentView>(
193
- `/api/saas/support/${ticketId}/attachment?${query}`,
194
- );
195
- window.open(file.url, "_blank", "noopener,noreferrer");
196
- } catch (error) {
197
- showError(error, "saas.support.error.attachment");
198
- }
199
- }
200
-
201
- function showError(error: unknown, fallback: string): void {
202
- toast.add({
203
- title: resolveApiError(error, fallback),
204
- color: "error",
205
- icon: "i-ph-warning-circle",
206
- });
207
- }
208
-
209
- onMounted(load);
210
-
211
- function changeListPage(page: number): void {
212
- listPage.value = page;
213
- void load();
214
- }
215
-
216
- function changeThreadPage(kind: "messages" | "events", page: number): void {
217
- if (!selected.value) return;
218
- const pages = { messages: messagesPage, events: eventsPage };
219
- pages[kind].value = page;
220
- void loadThreadPage(kind, selected.value.ticket._id);
221
- }
222
-
223
- async function loadThreadPage(
224
- kind: "messages" | "events",
225
- ticketId: string,
226
- ): Promise<void> {
227
- const requests = { messages: messagesRequest, events: eventsRequest };
228
- const request = ++requests[kind];
229
- if (kind === "messages") messagesRequest = request;
230
- else eventsRequest = request;
231
- try {
232
- const page = await $authFetch<
233
- SupportPageView<SupportMessageView | SupportTicketEventView>
234
- >(
235
- `/api/saas/support/${ticketId}/${kind}/list?${pageQuery(kind === "messages" ? messagesPage.value : eventsPage.value, "createdAt")}`,
236
- );
237
- const currentRequest =
238
- kind === "messages" ? messagesRequest : eventsRequest;
239
- if (request !== currentRequest || selected.value?.ticket._id !== ticketId)
240
- return;
241
- if (kind === "messages") {
242
- selected.value.messages = page as SupportPageView<SupportMessageView>;
243
- } else {
244
- selected.value.events = page as SupportPageView<SupportTicketEventView>;
245
- }
246
- } catch (error) {
247
- showError(error, "saas.support.error.load");
248
- }
249
- }
250
- </script>
251
-
252
- <template>
253
- <div class="grid gap-6 xl:grid-cols-[22rem_1fr]">
254
- <div class="flex flex-col gap-4">
255
- <UAlert
256
- v-if="config"
257
- color="primary"
258
- variant="subtle"
259
- icon="i-ph-clock-countdown"
260
- :title="$t(`saas.support.sla.${config.policy.level}.title`)"
261
- :description="$t(`saas.support.sla.${config.policy.level}.description`)"
262
- />
263
- <UButton icon="i-ph-plus" block @click="isCreateOpen = true">
264
- {{ $t("saas.support.tenant.new") }}
265
- </UButton>
266
- <USkeleton v-if="isLoading" class="h-32" />
267
- <UCard v-else-if="!tickets.length">
268
- <p class="text-muted text-center text-sm">
269
- {{ $t("saas.support.tenant.empty") }}
270
- </p>
271
- </UCard>
272
- <template v-else>
273
- <button
274
- v-for="ticket in tickets"
275
- :key="ticket._id"
276
- class="border-default hover:bg-elevated rounded-lg border p-4 text-left"
277
- :class="{
278
- 'ring-primary ring-2': selected?.ticket._id === ticket._id,
279
- }"
280
- @click="selectTicket(ticket._id)"
281
- >
282
- <div class="flex items-start justify-between gap-2">
283
- <p class="font-medium">{{ ticket.subject }}</p>
284
- <UBadge size="sm" variant="subtle">
285
- {{ $t(`saas.support.status.${ticket.status}`) }}
286
- </UBadge>
287
- </div>
288
- <p class="text-muted mt-2 text-xs">
289
- {{ formatDate(ticket.lastMessageAt) }}
290
- </p>
291
- </button>
292
- <UPagination
293
- v-if="ticketTotal > PAGE_SIZE"
294
- :page="listPage"
295
- :total="ticketTotal"
296
- :items-per-page="PAGE_SIZE"
297
- size="sm"
298
- class="self-center"
299
- @update:page="changeListPage"
300
- />
301
- </template>
302
- </div>
303
-
304
- <UCard v-if="selected">
305
- <template #header>
306
- <div class="flex flex-wrap items-start justify-between gap-3">
307
- <div>
308
- <h2 class="text-lg font-semibold">
309
- {{ selected.ticket.subject }}
310
- </h2>
311
- <p class="text-muted text-sm">
312
- {{ $t(`saas.support.category.${selected.ticket.category}`) }}
313
- ·
314
- {{ $t(`saas.support.priority.${selected.ticket.priority}`) }}
315
- </p>
316
- </div>
317
- <UBadge>
318
- {{ $t(`saas.support.status.${selected.ticket.status}`) }}
319
- </UBadge>
320
- </div>
321
- </template>
322
- <div class="flex flex-col gap-4">
323
- <SupportThreadHistory
324
- :messages="selected.messages"
325
- :events="selected.events"
326
- @attachment="openAttachment(selected.ticket._id, $event)"
327
- @messages-page="changeThreadPage('messages', $event)"
328
- @events-page="changeThreadPage('events', $event)"
329
- />
330
- <USeparator />
331
- <UTextarea
332
- v-model="replyBody"
333
- :placeholder="$t('saas.support.reply_placeholder')"
334
- :rows="4"
335
- class="w-full"
336
- />
337
- <SupportAttachmentUpload
338
- v-if="config"
339
- v-model="replyAttachments"
340
- multiple
341
- :constraints="attachmentConstraints"
342
- :disabled="isSubmitting"
343
- @busy="isReplyUploading = $event"
344
- />
345
- <div class="flex justify-end">
346
- <UButton
347
- :loading="isSubmitting"
348
- :disabled="isReplyUploading"
349
- @click="sendReply"
350
- >
351
- {{ $t("saas.support.send") }}
352
- </UButton>
353
- </div>
354
- </div>
355
- </UCard>
356
- <UCard v-else class="min-h-64">
357
- <div class="text-muted flex h-full items-center justify-center text-sm">
358
- {{ $t("saas.support.tenant.select") }}
359
- </div>
360
- </UCard>
361
- </div>
362
-
363
- <UModal v-model:open="isCreateOpen" :title="$t('saas.support.tenant.new')">
364
- <template #body>
365
- <div class="flex flex-col gap-4">
366
- <UFormField :label="$t('saas.support.subject')" required>
367
- <UInput v-model="draft.subject" class="w-full" />
368
- </UFormField>
369
- <div class="grid gap-4 sm:grid-cols-2">
370
- <UFormField :label="$t('saas.support.category.label')" required>
371
- <USelect
372
- v-model="draft.category"
373
- :items="categoryItems"
374
- class="w-full"
375
- />
376
- </UFormField>
377
- <UFormField :label="$t('saas.support.priority.label')" required>
378
- <USelect
379
- v-model="draft.priority"
380
- :items="priorityItems"
381
- class="w-full"
382
- />
383
- </UFormField>
384
- </div>
385
- <UFormField :label="$t('saas.support.message')" required>
386
- <UTextarea v-model="draft.body" :rows="5" class="w-full" />
387
- </UFormField>
388
- <UFormField :label="$t('saas.support.attachments')">
389
- <SupportAttachmentUpload
390
- v-if="config"
391
- v-model="draft.attachments"
392
- multiple
393
- :constraints="attachmentConstraints"
394
- :disabled="isSubmitting"
395
- @busy="isDraftUploading = $event"
396
- />
397
- </UFormField>
398
- </div>
399
- </template>
400
- <template #footer>
401
- <div class="flex w-full justify-end gap-2">
402
- <UButton color="neutral" variant="ghost" @click="isCreateOpen = false">
403
- {{ $t("common.cancel") }}
404
- </UButton>
405
- <UButton
406
- :loading="isSubmitting"
407
- :disabled="isDraftUploading"
408
- @click="createTicket"
409
- >
410
- {{ $t("saas.support.tenant.create") }}
411
- </UButton>
412
- </div>
413
- </template>
414
- </UModal>
415
- </template>