@frigear-nu/events 0.1.1 → 0.1.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 (97) hide show
  1. package/dist/module.d.mts +105 -1
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +40 -31
  4. package/dist/runtime/app/components/EventAddonGroupSlideover.d.vue.ts +18 -0
  5. package/dist/runtime/app/components/EventAddonGroupSlideover.vue +66 -0
  6. package/dist/runtime/app/components/EventAddonGroupSlideover.vue.d.ts +18 -0
  7. package/dist/runtime/app/components/EventAddonSlideover.d.vue.ts +20 -0
  8. package/dist/runtime/app/components/EventAddonSlideover.vue +103 -0
  9. package/dist/runtime/app/components/EventAddonSlideover.vue.d.ts +20 -0
  10. package/dist/runtime/app/components/EventAdminAddonGroups.d.vue.ts +1 -9
  11. package/dist/runtime/app/components/EventAdminAddonGroups.vue +6 -48
  12. package/dist/runtime/app/components/EventAdminAddonGroups.vue.d.ts +1 -9
  13. package/dist/runtime/app/components/EventFormSlideover.vue +45 -23
  14. package/dist/runtime/app/components/EventTicketCard.vue +59 -62
  15. package/dist/runtime/app/components/EventTicketTypeSlideover.d.vue.ts +20 -0
  16. package/dist/runtime/app/components/EventTicketTypeSlideover.vue +176 -0
  17. package/dist/runtime/app/components/EventTicketTypeSlideover.vue.d.ts +20 -0
  18. package/dist/runtime/app/composables/index.d.ts +2 -0
  19. package/dist/runtime/app/composables/index.js +1 -0
  20. package/dist/runtime/app/composables/useEventActions.d.ts +3 -3
  21. package/dist/runtime/app/composables/useEventActions.js +9 -4
  22. package/dist/runtime/app/composables/useEventCatalogActions.d.ts +33 -0
  23. package/dist/runtime/app/composables/useEventCatalogActions.js +107 -0
  24. package/dist/runtime/app/composables/useEvents.d.ts +7 -6
  25. package/dist/runtime/app/composables/useEvents.js +17 -10
  26. package/dist/runtime/app/pages/admin-event.vue +71 -136
  27. package/dist/runtime/app/pages/admin.vue +91 -29
  28. package/dist/runtime/app/pages/event.vue +8 -4
  29. package/dist/runtime/app/pages/tickets.vue +24 -2
  30. package/dist/runtime/app/tabs/Attendees.vue +78 -39
  31. package/dist/runtime/app/tabs/Details.d.vue.ts +1 -1
  32. package/dist/runtime/app/tabs/Details.vue +112 -30
  33. package/dist/runtime/app/tabs/Details.vue.d.ts +1 -1
  34. package/dist/runtime/app/tabs/Tickets.vue +105 -132
  35. package/dist/runtime/app/utils/admin-event.d.ts +53 -38
  36. package/dist/runtime/app/utils/admin-event.js +14 -0
  37. package/dist/runtime/app/widgets/UpcomingEvents.vue +2 -1
  38. package/dist/runtime/app/widgets/UpcomingEventsStat.vue +2 -1
  39. package/dist/runtime/i18n/lang/da.json +24 -3
  40. package/dist/runtime/i18n/lang/en.json +24 -3
  41. package/dist/runtime/server/bootstrap/my-tickets.d.ts +7 -0
  42. package/dist/runtime/server/{routes/public/tickets-me.get.js → bootstrap/my-tickets.js} +2 -2
  43. package/dist/runtime/server/routes/admin/addon-group.delete.js +6 -3
  44. package/dist/runtime/server/routes/admin/addon-group.patch.js +6 -4
  45. package/dist/runtime/server/routes/admin/addon-groups.post.js +8 -6
  46. package/dist/runtime/server/routes/admin/addon.delete.js +6 -3
  47. package/dist/runtime/server/routes/admin/addon.patch.js +8 -5
  48. package/dist/runtime/server/routes/admin/addons.post.js +8 -6
  49. package/dist/runtime/server/routes/admin/attendees.get.js +38 -8
  50. package/dist/runtime/server/routes/{public → admin}/check-in.post.js +4 -3
  51. package/dist/runtime/server/routes/admin/event-clone.post.js +37 -19
  52. package/dist/runtime/server/routes/admin/event.delete.js +7 -4
  53. package/dist/runtime/server/routes/admin/event.get.js +4 -2
  54. package/dist/runtime/server/routes/admin/event.patch.js +4 -2
  55. package/dist/runtime/server/routes/admin/events.get.js +36 -3
  56. package/dist/runtime/server/routes/admin/events.post.js +3 -4
  57. package/dist/runtime/server/routes/admin/membership-options.get.js +3 -2
  58. package/dist/runtime/server/routes/admin/ticket-cancel.post.js +7 -8
  59. package/dist/runtime/server/routes/admin/ticket-refund.post.js +7 -5
  60. package/dist/runtime/server/routes/admin/ticket-type.delete.js +7 -4
  61. package/dist/runtime/server/routes/admin/ticket-type.patch.js +4 -2
  62. package/dist/runtime/server/routes/admin/ticket-types.post.js +10 -7
  63. package/dist/runtime/server/routes/admin/upcoming.get.js +7 -3
  64. package/dist/runtime/server/routes/me/ticket-pay.post.js +13 -0
  65. package/dist/runtime/server/routes/me/ticket.get.js +6 -0
  66. package/dist/runtime/server/routes/me/tickets.get.js +2 -0
  67. package/dist/runtime/server/routes/public/event.get.js +5 -2
  68. package/dist/runtime/server/routes/public/purchase.post.js +3 -1
  69. package/dist/runtime/server/utils/admin-dto.d.ts +30 -0
  70. package/dist/runtime/server/utils/admin-dto.js +101 -0
  71. package/dist/runtime/server/utils/admin-schemas.js +2 -1
  72. package/dist/runtime/server/utils/admin.d.ts +3 -1
  73. package/dist/runtime/server/utils/admin.js +20 -6
  74. package/dist/runtime/server/utils/auth.d.ts +5 -4
  75. package/dist/runtime/server/utils/auth.js +6 -7
  76. package/dist/runtime/server/utils/tickets.js +6 -11
  77. package/dist/runtime/utils/api.d.ts +48 -0
  78. package/dist/runtime/utils/api.js +51 -0
  79. package/dist/runtime/utils/index.d.ts +2 -1
  80. package/dist/runtime/utils/index.js +1 -0
  81. package/dist/types.d.mts +1 -1
  82. package/package.json +21 -16
  83. package/dist/runtime/app/components/EventAdminAddonDialog.d.vue.ts +0 -11
  84. package/dist/runtime/app/components/EventAdminAddonDialog.vue +0 -112
  85. package/dist/runtime/app/components/EventAdminAddonDialog.vue.d.ts +0 -11
  86. package/dist/runtime/app/components/EventAdminAddonGroupDialog.d.vue.ts +0 -11
  87. package/dist/runtime/app/components/EventAdminAddonGroupDialog.vue +0 -78
  88. package/dist/runtime/app/components/EventAdminAddonGroupDialog.vue.d.ts +0 -11
  89. package/dist/runtime/app/components/EventAdminTicketTypeDialog.d.vue.ts +0 -20
  90. package/dist/runtime/app/components/EventAdminTicketTypeDialog.vue +0 -174
  91. package/dist/runtime/app/components/EventAdminTicketTypeDialog.vue.d.ts +0 -20
  92. package/dist/runtime/server/routes/public/ticket-pay.post.js +0 -10
  93. package/dist/runtime/server/routes/public/ticket.get.js +0 -4
  94. /package/dist/runtime/server/routes/{public → admin}/check-in.post.d.ts +0 -0
  95. /package/dist/runtime/server/routes/{public → me}/ticket-pay.post.d.ts +0 -0
  96. /package/dist/runtime/server/routes/{public → me}/ticket.get.d.ts +0 -0
  97. /package/dist/runtime/server/routes/{public/tickets-me.get.d.ts → me/tickets.get.d.ts} +0 -0
@@ -1,4 +1,6 @@
1
1
  <script setup>
2
+ import { refDebounced } from "@vueuse/core";
3
+ import { eventsApi } from "../../utils/api";
2
4
  defineOptions({ name: "EventsAdminAttendeesTab" });
3
5
  const props = defineProps({
4
6
  event: { type: Object, required: true }
@@ -8,7 +10,18 @@ const tr = useEventsTranslate();
8
10
  const format = useFormat();
9
11
  const confirmAction = useConfirmDialog();
10
12
  const { run } = useAction();
11
- const { data: attendees, refresh } = await useFetch(() => `/api/events/admin/events/${props.event.id}/tickets`, { default: () => [] });
13
+ const page = ref(1);
14
+ const pageSize = ref(50);
15
+ const search = ref("");
16
+ const q = refDebounced(search, 300);
17
+ watch([q, pageSize], () => {
18
+ page.value = 1;
19
+ });
20
+ const query = computed(() => ({ page: page.value, pageSize: pageSize.value, q: q.value.trim() || void 0 }));
21
+ const { data, status, error, refresh } = await useFetch(() => eventsApi.admin.attendees(props.event.id), { query });
22
+ const rows = computed(() => data.value?.items ?? []);
23
+ const loading = computed(() => status.value === "pending" && !data.value);
24
+ const refreshing = computed(() => status.value === "pending" && !!data.value);
12
25
  const typeName = (ticketTypeId) => tr(props.event.ticketTypes.find((item) => item.id === ticketTypeId)?.name);
13
26
  const who = (ticket) => ticket.userName || ticket.userEmail || "";
14
27
  const ticketAction = async (ticket, action) => {
@@ -20,7 +33,7 @@ const ticketAction = async (ticket, action) => {
20
33
  });
21
34
  if (!confirmed) return;
22
35
  return run(async () => {
23
- await $fetch(`/api/events/admin/tickets/${ticket.id}/${action}`, { method: "POST" });
36
+ await $fetch(action === "refund" ? eventsApi.admin.refundTicket(ticket.id) : eventsApi.admin.cancelTicket(ticket.id), { method: "POST" });
24
37
  await refresh();
25
38
  }, { success: action === "refund" ? t("admin.refunded", "Refunded.") : t("admin.cancelled", "Cancelled.") });
26
39
  };
@@ -33,53 +46,71 @@ const menu = (ticket) => {
33
46
  } }] : [];
34
47
  return [refund, cancel].filter((group) => group.length);
35
48
  };
49
+ const TableIdentity = resolveComponent("TableIdentity");
50
+ const StatusBadge = resolveComponent("StatusBadge");
51
+ const RowMenu = resolveComponent("RowMenu");
36
52
  const columns = computed(() => [
37
- { accessorKey: "userName", header: t("admin.attendee", "Attendee") },
38
- { accessorKey: "ticketTypeId", header: t("admin.ticketType", "Ticket") },
39
- { accessorKey: "status", header: t("admin.status", "Status") },
40
- { accessorKey: "checkedInAt", header: t("admin.checkedIn", "Checked in") },
41
- { id: "actions" }
53
+ {
54
+ accessorKey: "userName",
55
+ header: t("admin.attendee", "Attendee"),
56
+ cell: ({ row }) => h(TableIdentity, { title: who(row.original), description: row.original.userName ? row.original.userEmail : null })
57
+ },
58
+ {
59
+ accessorKey: "ticketTypeId",
60
+ header: t("admin.ticketType", "Ticket"),
61
+ cell: ({ row }) => typeName(row.original.ticketTypeId) || emptyCell()
62
+ },
63
+ {
64
+ accessorKey: "status",
65
+ header: t("admin.status", "Status"),
66
+ cell: ({ row }) => h(StatusBadge, { status: row.original.status, label: t(`tickets.status.${row.original.status}`, row.original.status) })
67
+ },
68
+ {
69
+ accessorKey: "checkedInAt",
70
+ header: t("admin.checkedIn", "Checked in"),
71
+ cell: ({ row }) => row.original.checkedInAt ? h("span", { class: "tabular-nums" }, format.dateTime(row.original.checkedInAt)) : emptyCell()
72
+ },
73
+ {
74
+ id: "actions",
75
+ meta: { class: { th: "w-0", td: "w-0" } },
76
+ cell: ({ row }) => {
77
+ const items = menu(row.original);
78
+ return items.length ? h(RowMenu, { items, subject: who(row.original) }) : null;
79
+ }
80
+ }
42
81
  ]);
43
82
  </script>
44
83
 
45
84
  <template>
46
- <SectionCard
85
+ <TableCard
47
86
  :title="t('admin.attendees', 'Attendees')"
48
- flush
87
+ :loading="loading"
88
+ :refreshing="refreshing"
49
89
  >
90
+ <template #actions>
91
+ <UInput
92
+ v-model="search"
93
+ icon="i-lucide-search"
94
+ size="sm"
95
+ :placeholder="t('admin.searchAttendees', 'Search name or email')"
96
+ :aria-label="t('admin.searchAttendees', 'Search name or email')"
97
+ class="w-full sm:max-w-xs"
98
+ />
99
+ </template>
100
+ <UEmpty
101
+ v-if="error && !data"
102
+ variant="naked"
103
+ icon="i-lucide-circle-alert"
104
+ :title="t('admin.attendeesLoadFailed', 'Attendees could not be loaded')"
105
+ :description="errorMessage(error, '')"
106
+ :actions="[{ label: t('tryAgain', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => refresh() }]"
107
+ />
50
108
  <UTable
51
- :data="attendees"
109
+ v-else
110
+ :data="rows"
52
111
  :columns="columns"
53
112
  :ui="tableUi({ tr: '' })"
54
113
  >
55
- <template #userName-cell="{ row }">
56
- <span class="text-highlighted font-medium">{{ row.original.userName }}</span>
57
- <span class="text-muted"> · {{ row.original.userEmail }}</span>
58
- </template>
59
- <template #ticketTypeId-cell="{ row }">
60
- {{ typeName(row.original.ticketTypeId) }}
61
- </template>
62
- <template #status-cell="{ row }">
63
- <UBadge
64
- variant="subtle"
65
- :color="statusColor(row.original.status)"
66
- :label="t(`tickets.status.${row.original.status}`, row.original.status)"
67
- />
68
- </template>
69
- <template #checkedInAt-cell="{ row }">
70
- <span v-if="row.original.checkedInAt">{{ format.dateTime(row.original.checkedInAt) }}</span>
71
- <span
72
- v-else
73
- class="text-muted"
74
- >–</span>
75
- </template>
76
- <template #actions-cell="{ row }">
77
- <RowMenu
78
- v-if="menu(row.original).length"
79
- :items="menu(row.original)"
80
- :subject="who(row.original)"
81
- />
82
- </template>
83
114
  <template #empty>
84
115
  <UEmpty
85
116
  variant="naked"
@@ -88,5 +119,13 @@ const columns = computed(() => [
88
119
  />
89
120
  </template>
90
121
  </UTable>
91
- </SectionCard>
122
+ <template #footer>
123
+ <TablePagination
124
+ v-model:page="page"
125
+ v-model:page-size="pageSize"
126
+ :page-sizes="[50, 100]"
127
+ :total="data?.total ?? 0"
128
+ />
129
+ </template>
130
+ </TableCard>
92
131
  </template>
@@ -1,4 +1,4 @@
1
- import type { StaffEvent } from '../utils/admin-event.js';
1
+ import { type StaffEvent } from '../utils/admin-event.js';
2
2
  type __VLS_Props = {
3
3
  event: StaffEvent;
4
4
  };
@@ -1,4 +1,6 @@
1
1
  <script setup>
2
+ import { eventsApi } from "../../utils/api";
3
+ import { EVENT_STATUSES, eventFormSchema, toLocalInput } from "../utils/admin-event";
2
4
  defineOptions({ name: "EventsAdminDetailsTab" });
3
5
  const props = defineProps({
4
6
  event: { type: Object, required: true }
@@ -7,65 +9,145 @@ const emit = defineEmits(["refresh"]);
7
9
  const t = useModuleT("events");
8
10
  const tr = useEventsTranslate();
9
11
  const { busy, run } = useAction();
10
- const form = reactive({
11
- name: void 0,
12
- description: void 0,
13
- body: void 0
14
- });
12
+ const schema = eventFormSchema(t, tr);
13
+ const statuses = computed(() => EVENT_STATUSES.map((value) => ({ value, label: t(`admin.statuses.${value}`, value.charAt(0).toUpperCase() + value.slice(1)) })));
14
+ const state = reactive({ name: void 0, slug: "", startsAt: "", endsAt: "", status: "draft" });
15
15
  function fill(value) {
16
- Object.assign(form, { name: value.name, description: value.description ?? void 0, body: value.body ?? void 0 });
16
+ Object.assign(state, {
17
+ name: value.name,
18
+ slug: value.slug,
19
+ status: value.status,
20
+ startsAt: toLocalInput(value.startsAt),
21
+ endsAt: toLocalInput(value.endsAt),
22
+ location: value.location ?? void 0,
23
+ description: value.description ?? void 0,
24
+ body: value.body ?? void 0
25
+ });
17
26
  }
18
27
  fill(props.event);
19
28
  watch(() => props.event, fill);
20
- const save = () => run(async () => {
21
- await $fetch(`/api/events/admin/events/${props.event.id}`, {
22
- method: "PATCH",
23
- body: { name: form.name, description: form.description ?? null, body: form.body ?? null }
24
- });
25
- emit("refresh");
26
- }, { success: t("admin.saved", "Saved.") });
29
+ async function save(event) {
30
+ const data = event.data;
31
+ const saved = await run(
32
+ () => $fetch(eventsApi.admin.event(props.event.id), {
33
+ method: "PATCH",
34
+ body: {
35
+ name: data.name,
36
+ slug: data.slug,
37
+ status: data.status,
38
+ startsAt: data.startsAt,
39
+ endsAt: data.endsAt || null,
40
+ location: data.location ?? null,
41
+ description: data.description ?? null,
42
+ body: data.body ?? null
43
+ }
44
+ }).then(() => true),
45
+ { success: t("admin.saved", "Saved.") }
46
+ );
47
+ if (saved) emit("refresh");
48
+ }
27
49
  </script>
28
50
 
29
51
  <template>
30
52
  <SectionCard :title="t('admin.details', 'Details')">
31
- <form
53
+ <UForm
32
54
  id="admin-event-details"
33
- class="flex flex-col gap-4"
34
- @submit.prevent="save"
55
+ :schema="schema"
56
+ :state="state"
57
+ class="flex flex-col divide-y divide-default"
58
+ @submit="save"
35
59
  >
36
- <UFormField :label="t('admin.name', 'Name')">
60
+ <FormRow
61
+ name="name"
62
+ :label="t('admin.name', 'Name')"
63
+ required
64
+ >
37
65
  <TranslatedInput
38
- v-model="form.name"
39
- required
66
+ v-model="state.name"
67
+ class="sm:max-w-sm"
68
+ />
69
+ </FormRow>
70
+ <FormRow
71
+ name="status"
72
+ :label="t('admin.status', 'Status')"
73
+ >
74
+ <USelect
75
+ v-model="state.status"
76
+ :items="statuses"
77
+ class="w-full sm:max-w-sm"
78
+ />
79
+ </FormRow>
80
+ <FormRow
81
+ name="slug"
82
+ :label="t('admin.slug', 'URL name')"
83
+ :description="t('admin.slugHelp', 'Lowercase letters, numbers and dashes.')"
84
+ required
85
+ >
86
+ <UInput
87
+ v-model="state.slug"
88
+ class="w-full sm:max-w-sm"
40
89
  />
41
- </UFormField>
42
- <UFormField :label="t('admin.description', 'Description')">
90
+ </FormRow>
91
+ <FormRow
92
+ name="startsAt"
93
+ :label="t('admin.starts', 'Starts')"
94
+ required
95
+ >
96
+ <UInput
97
+ v-model="state.startsAt"
98
+ type="datetime-local"
99
+ class="w-full sm:max-w-sm"
100
+ />
101
+ </FormRow>
102
+ <FormRow
103
+ name="endsAt"
104
+ :label="t('admin.ends', 'Ends')"
105
+ >
106
+ <UInput
107
+ v-model="state.endsAt"
108
+ type="datetime-local"
109
+ class="w-full sm:max-w-sm"
110
+ />
111
+ </FormRow>
112
+ <FormRow
113
+ name="location"
114
+ :label="t('admin.location', 'Location')"
115
+ >
116
+ <TranslatedInput
117
+ v-model="state.location"
118
+ class="sm:max-w-sm"
119
+ />
120
+ </FormRow>
121
+ <FormRow
122
+ name="description"
123
+ :label="t('admin.description', 'Description')"
124
+ >
43
125
  <TranslatedInput
44
- v-model="form.description"
126
+ v-model="state.description"
45
127
  multiline
46
128
  mdc
47
129
  />
48
- </UFormField>
49
- <UFormField
130
+ </FormRow>
131
+ <FormRow
132
+ name="body"
50
133
  :label="t('admin.body', 'Details')"
51
134
  :description="t('admin.bodyHelp', 'Shown on the event page. Markdown is supported.')"
52
135
  >
53
136
  <TranslatedInput
54
- v-model="form.body"
137
+ v-model="state.body"
55
138
  multiline
56
139
  mdc
57
140
  :rows="8"
58
141
  />
59
- </UFormField>
60
- </form>
142
+ </FormRow>
143
+ </UForm>
61
144
  <template #footer>
62
- <div class="flex justify-end">
145
+ <div class="flex justify-end gap-2">
63
146
  <UButton
64
147
  type="submit"
65
148
  form="admin-event-details"
66
149
  :loading="busy"
67
- :disabled="!tr(form.name)"
68
- :label="t('admin.save', 'Save')"
150
+ :label="t('admin.saveEvent', 'Save event')"
69
151
  />
70
152
  </div>
71
153
  </template>
@@ -1,4 +1,4 @@
1
- import type { StaffEvent } from '../utils/admin-event.js';
1
+ import { type StaffEvent } from '../utils/admin-event.js';
2
2
  type __VLS_Props = {
3
3
  event: StaffEvent;
4
4
  };
@@ -1,4 +1,5 @@
1
1
  <script setup>
2
+ import { eventsApi } from "../../utils/api";
2
3
  defineOptions({ name: "EventsAdminTicketsTab" });
3
4
  const props = defineProps({
4
5
  event: { type: Object, required: true }
@@ -7,151 +8,123 @@ const emit = defineEmits(["refresh"]);
7
8
  const t = useModuleT("events");
8
9
  const format = useFormat();
9
10
  const tr = useEventsTranslate();
10
- const confirmAction = useConfirmDialog();
11
- const { run } = useAction();
12
11
  const refresh = () => emit("refresh");
12
+ const { data: membershipOptions, loading: optionsLoading } = useCachedFetch(eventsApi.admin.membershipOptions, {
13
+ key: "events:admin:membership-options",
14
+ ttl: 5 * 6e4
15
+ });
16
+ const plans = computed(() => (membershipOptions.value?.options ?? []).map((option) => ({ label: option.label, value: option.value })));
17
+ const planLabel = (key) => plans.value.find((plan) => plan.value === key)?.label ?? key;
18
+ const describeRule = (value) => value === "any" ? t("admin.ruleAny", "Any membership") : Array.isArray(value) ? value.map(planLabel).join(", ") : "";
19
+ const catalog = useEventCatalogActions({
20
+ eventId: () => props.event.id,
21
+ onChanged: refresh,
22
+ membership: () => ({ enabled: membershipOptions.value?.available === true, plans: plans.value })
23
+ });
24
+ const RowMenu = resolveComponent("RowMenu");
13
25
  const typeColumns = computed(() => [
14
- { id: "expand" },
15
- { accessorKey: "name", header: t("admin.name", "Name") },
16
- { accessorKey: "price", header: t("admin.price", "Price") },
17
- { accessorKey: "capacity", header: t("admin.capacity", "Capacity") },
26
+ { id: "expand", meta: { class: { th: "w-0", td: "w-0" } } },
27
+ {
28
+ accessorKey: "name",
29
+ header: t("admin.name", "Name"),
30
+ cell: ({ row }) => h("span", { class: "text-highlighted font-medium" }, tr(row.original.name))
31
+ },
32
+ {
33
+ accessorKey: "price",
34
+ header: t("admin.price", "Price"),
35
+ meta: numericColumn,
36
+ cell: ({ row }) => h("span", { class: "whitespace-nowrap" }, format.money(row.original.price, row.original.currency))
37
+ },
38
+ {
39
+ accessorKey: "capacity",
40
+ header: t("admin.capacity", "Capacity"),
41
+ meta: numericColumn,
42
+ cell: ({ row }) => row.original.capacity ?? emptyCell()
43
+ },
18
44
  { id: "access", header: t("admin.access", "Access") },
19
- { id: "addons", header: t("admin.addonGroups", "Add-ons") },
20
- { id: "actions" }
45
+ {
46
+ id: "addons",
47
+ header: t("admin.addonGroups", "Add-ons"),
48
+ meta: numericColumn,
49
+ cell: ({ row }) => row.original.addonGroups.length
50
+ },
51
+ {
52
+ id: "actions",
53
+ meta: { class: { th: "w-0", td: "w-0" } },
54
+ cell: ({ row }) => h(RowMenu, { items: catalog.typeMenu(row.original), subject: tr(row.original.name) })
55
+ }
21
56
  ]);
22
- const { data: membershipOptions } = useLazyFetch("/api/events/admin/membership-options", { default: () => ({ available: false, options: [] }) });
23
- const membershipEnabled = computed(() => membershipOptions.value.available);
24
- const plans = computed(() => membershipOptions.value.options.map((option) => ({ label: option.label, value: option.value })));
25
- const planLabel = (key) => plans.value.find((plan) => plan.value === key)?.label ?? key;
26
- const describeRule = (value) => value === "any" ? t("admin.ruleAny", "Any membership") : Array.isArray(value) ? value.map(planLabel).join(", ") : "";
27
- const typeDialog = useTemplateRef("typeDialog");
28
- const groupDialog = useTemplateRef("groupDialog");
29
- const addonDialog = useTemplateRef("addonDialog");
30
- const removeType = async (ticketType) => {
31
- const name = tr(ticketType.name);
32
- if (!await confirmAction({
33
- title: t("admin.confirmRemoveType", "Delete {name}?", { name }),
34
- description: t("admin.confirmRemoveTypeDescription", "Its add-on groups and choices are deleted too. A ticket type with active tickets cannot be deleted."),
35
- confirmText: t("admin.removeType", "Delete ticket type"),
36
- destructive: true
37
- })) return;
38
- return run(async () => {
39
- await $fetch(`/api/events/admin/ticket-types/${ticketType.id}`, { method: "DELETE" });
40
- await refresh();
41
- }, { success: t("admin.typeDeleted", "Deleted {name}", { name }) });
42
- };
43
- const menu = (ticketType) => [
44
- [{ label: t("admin.editType", "Edit ticket type"), icon: "i-lucide-pencil", onSelect: () => typeDialog.value?.open(ticketType) }],
45
- [{ label: t("admin.removeType", "Delete ticket type"), icon: "i-lucide-trash-2", color: "error", onSelect: () => {
46
- void removeType(ticketType);
47
- } }]
48
- ];
49
57
  </script>
50
58
 
51
59
  <template>
52
- <div class="flex flex-col gap-4">
53
- <SectionCard
54
- :title="t('admin.ticketTypes', 'Ticket types')"
55
- flush
60
+ <TableCard :title="t('admin.ticketTypes', 'Ticket types')">
61
+ <template #actions>
62
+ <UButton
63
+ icon="i-lucide-plus"
64
+ variant="outline"
65
+ color="neutral"
66
+ size="xs"
67
+ :label="t('admin.addType', 'Add ticket type')"
68
+ @click="catalog.openTicketType()"
69
+ />
70
+ </template>
71
+ <UTable
72
+ :data="event.ticketTypes"
73
+ :columns="typeColumns"
74
+ :ui="tableUi({ tr: 'data-[expanded=true]:bg-elevated/30' })"
56
75
  >
57
- <template #actions>
76
+ <template #expand-cell="{ row }">
58
77
  <UButton
59
- icon="i-lucide-plus"
60
- variant="outline"
61
78
  color="neutral"
79
+ variant="ghost"
62
80
  size="xs"
63
- :label="t('admin.addType', 'Add ticket type')"
64
- @click="typeDialog?.open()"
81
+ :icon="row.getIsExpanded() ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
82
+ :aria-label="t('admin.addonGroups', 'Add-ons')"
83
+ :aria-expanded="row.getIsExpanded()"
84
+ @click="row.toggleExpanded()"
65
85
  />
66
86
  </template>
67
- <UTable
68
- :data="event.ticketTypes"
69
- :columns="typeColumns"
70
- :ui="tableUi({ tr: 'data-[expanded=true]:bg-elevated/30' })"
71
- >
72
- <template #expand-cell="{ row }">
73
- <UButton
74
- color="neutral"
75
- variant="ghost"
76
- size="xs"
77
- :icon="row.getIsExpanded() ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
78
- :aria-label="t('admin.addonGroups', 'Add-ons')"
79
- @click="row.toggleExpanded()"
80
- />
81
- </template>
82
- <template #name-cell="{ row }">
83
- <span class="text-highlighted font-medium">{{ tr(row.original.name) }}</span>
84
- </template>
85
- <template #price-cell="{ row }">
86
- <span class="whitespace-nowrap">{{ format.money(row.original.price, row.original.currency) }}</span>
87
- </template>
88
- <template #capacity-cell="{ row }">
89
- {{ row.original.capacity ?? "\u2013" }}
90
- </template>
91
- <template #access-cell="{ row }">
92
- <div class="flex flex-wrap gap-1">
93
- <UBadge
94
- v-if="row.original.requiresMembership"
95
- variant="subtle"
96
- color="success"
97
- :label="`${t('admin.requires', 'Requires')}: ${describeRule(row.original.requiresMembership)}`"
98
- />
99
- <UBadge
100
- v-if="row.original.excludesMembership"
101
- variant="subtle"
102
- color="warning"
103
- :label="`${t('admin.notFor', 'Not for')}: ${describeRule(row.original.excludesMembership)}`"
104
- />
105
- <span
106
- v-if="!row.original.requiresMembership && !row.original.excludesMembership"
107
- class="text-muted"
108
- >–</span>
109
- </div>
110
- </template>
111
- <template #addons-cell="{ row }">
112
- {{ row.original.addonGroups.length }}
113
- </template>
114
- <template #actions-cell="{ row }">
115
- <RowMenu
116
- :items="menu(row.original)"
117
- :subject="tr(row.original.name)"
118
- />
119
- </template>
120
- <template #expanded="{ row }">
121
- <EventAdminAddonGroups
122
- :ticket-type="row.original"
123
- @refresh="refresh"
124
- @add-group="groupDialog?.open(row.original)"
125
- @edit-group="(group) => groupDialog?.open(row.original, group)"
126
- @add-addon="(group) => addonDialog?.open(group, row.original.currency)"
127
- @edit-addon="(group, addon) => addonDialog?.open(group, row.original.currency, addon)"
87
+ <template #access-cell="{ row }">
88
+ <USkeleton
89
+ v-if="optionsLoading && (row.original.requiresMembership || row.original.excludesMembership)"
90
+ class="h-5 w-28"
91
+ />
92
+ <div
93
+ v-else-if="row.original.requiresMembership || row.original.excludesMembership"
94
+ class="flex flex-wrap gap-1"
95
+ >
96
+ <UBadge
97
+ v-if="row.original.requiresMembership"
98
+ variant="subtle"
99
+ color="success"
100
+ :label="`${t('admin.requires', 'Requires')}: ${describeRule(row.original.requiresMembership)}`"
128
101
  />
129
- </template>
130
- <template #empty>
131
- <UEmpty
132
- variant="naked"
133
- icon="i-lucide-ticket"
134
- :title="t('admin.noTypes', 'No ticket types yet: nobody can buy a ticket.')"
135
- :actions="[{ label: t('admin.addType', 'Add ticket type'), icon: 'i-lucide-plus', onClick: () => typeDialog?.open() }]"
102
+ <UBadge
103
+ v-if="row.original.excludesMembership"
104
+ variant="subtle"
105
+ color="warning"
106
+ :label="`${t('admin.notFor', 'Not for')}: ${describeRule(row.original.excludesMembership)}`"
136
107
  />
137
- </template>
138
- </UTable>
139
- </SectionCard>
140
-
141
- <EventAdminTicketTypeDialog
142
- ref="typeDialog"
143
- :event-id="props.event.id"
144
- :membership-enabled="membershipEnabled"
145
- :plans="plans"
146
- @refresh="refresh"
147
- />
148
- <EventAdminAddonGroupDialog
149
- ref="groupDialog"
150
- @refresh="refresh"
151
- />
152
- <EventAdminAddonDialog
153
- ref="addonDialog"
154
- @refresh="refresh"
155
- />
156
- </div>
108
+ </div>
109
+ <component
110
+ :is="emptyCell"
111
+ v-else
112
+ />
113
+ </template>
114
+ <template #expanded="{ row }">
115
+ <EventAdminAddonGroups
116
+ :ticket-type="row.original"
117
+ @refresh="refresh"
118
+ />
119
+ </template>
120
+ <template #empty>
121
+ <UEmpty
122
+ variant="naked"
123
+ icon="i-lucide-ticket"
124
+ :title="t('admin.noTypes', 'No ticket types yet: nobody can buy a ticket.')"
125
+ :actions="[{ label: t('admin.addType', 'Add ticket type'), icon: 'i-lucide-plus', onClick: () => catalog.openTicketType() }]"
126
+ />
127
+ </template>
128
+ </UTable>
129
+ </TableCard>
157
130
  </template>