Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(admin): events management #7

Merged
merged 31 commits into from
Jan 6, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ec47dae
wip: events table
Ethan-Chew Oct 8, 2023
97da2ad
feat: create and delete event
Ethan-Chew Oct 9, 2023
d882b2e
feat: event information
Ethan-Chew Oct 9, 2023
7bdd607
fix: attendees import
Ethan-Chew Oct 9, 2023
7b63805
fix: checked-in users
Ethan-Chew Oct 13, 2023
c03767e
feat: add nuxtui login
Ethan-Chew Oct 14, 2023
23e3cb1
feat: add image URL validation
Ethan-Chew Oct 14, 2023
856c4f8
fix: verify image url on server
Ethan-Chew Oct 14, 2023
895a062
feat: update event
Ethan-Chew Oct 15, 2023
1895948
fix: restrictTo exco for update
Ethan-Chew Oct 15, 2023
2732319
fix: resolved most comments
Ethan-Chew Oct 21, 2023
9a54153
fix: removed difference check when updating
Ethan-Chew Oct 23, 2023
5bb8c7f
Merge branch 'main' into feat/nuxtui
Ethan-Chew Oct 24, 2023
1cbe61f
fix: readded pnpm-lock
Ethan-Chew Oct 24, 2023
658acda
fix: update name case
Ethan-Chew Oct 24, 2023
fa07bab
Revert "fix: update name case"
qin-guan Oct 28, 2023
efa799b
fix: lint rules + dep upgrade
qin-guan Oct 28, 2023
6caf032
chore: remote stash
qin-guan Oct 28, 2023
71172b1
fix: reload on scope change
qin-guan Oct 29, 2023
7522edb
chore: remote stash
qin-guan Oct 30, 2023
7c3ccf0
feat: schema update
qin-guan Nov 2, 2023
ad39696
chore: dep upgrade and some fixes
qin-guan Jan 3, 2024
32fe27f
chore: update
qin-guan Jan 5, 2024
8885f74
feat: remove ts-rest, fix type issues, use new persister
qin-guan Jan 6, 2024
69eb3a1
chore: wip
qin-guan Jan 6, 2024
ab0c981
feat: update
qin-guan Jan 6, 2024
da1c371
feat: clean up
qin-guan Jan 6, 2024
f53d606
feat: add buster
qin-guan Jan 6, 2024
5df3ada
Merge branch 'main' into feat/nuxtui
qin-guan Jan 6, 2024
81857d6
chore: lint
qin-guan Jan 6, 2024
14f3eb7
feat: members page
qin-guan Jan 6, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@ export default defineAppConfig({
ui: {
gray: 'stone',
primary: 'red',
notifications: {
position: 'bottom-0 left-0',
},
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this is included?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For configuring where the NuxtUI notifications will show

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh, by default it doesn't show correctly?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does, but shows on the bottom right. Saw it from the documentation that you can configure the rendering location by adding it there (https://ui.nuxt.com/overlays/notification)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Does this affect mobile? Or will it show bottom center on mobile?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'll return bottom center on mobile

},
})
33 changes: 33 additions & 0 deletions components/admin/event/event-checkedin-users.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { EventWithAttendees } from '~/shared/types'

const props = defineProps<{
event: EventWithAttendees
}>()
</script>

<template>
<UCard>
<template #header>
<span class="font-semibold text-2xl">Attendees</span>
</template>
<div v-for="attendee in props.event.attendees" v-if="props.event.attendees.length > 0" :key="attendee.id">
Ethan-Chew marked this conversation as resolved.
Show resolved Hide resolved
<div class="mb-3">
<p class="font-bold text-xl">
{{ attendee.name }}
</p>
<p><span class="font-semibold">ID: </span>{{ attendee.id }}</p>
<hr class="my-2">
</div>
</div>
Ethan-Chew marked this conversation as resolved.
Show resolved Hide resolved
<div v-if="props.event.attendees.length === 0" class="flex justify-center align-items-center">
<div class="text-center">
<UIcon class="text-5xl" name="i-heroicons-face-frown" />
<p class="font-bold text-xl">
No attendees yet!
</p>
<p>Upload attendees using a .csv file first!</p>
</div>
Ethan-Chew marked this conversation as resolved.
Show resolved Hide resolved
</div>
</UCard>
</template>
38 changes: 38 additions & 0 deletions components/admin/event/event-information.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import type { EventWithAttendees } from '~/shared/types'

const props = defineProps<{
event: EventWithAttendees
Ethan-Chew marked this conversation as resolved.
Show resolved Hide resolved
}>()
</script>

<template>
<UCard>
<template #header>
<div class="relative">
<img :src="props.event.badgeImage" class="object-cover w-full max-h-60 object-top rounded-xl">
<div class="p-10 absolute z-10 inset-0 flex flex-col bg-gray-800/[0.3] space-y-6 backdrop-blur-sm rounded-xl">
<div class="space-y-3 flex flex-col">
<span class="font-bold text-3xl md:text-4xl">{{ props.event.name }}</span>
<div>
<div class="flex-inline shrink grow-0 p-1 px-4 border rounded-lg">
{{ `0 / ${event.attendees.length}` }}
</div>
</div>
</div>
<div class="text-lg flex flex-col">
<span><span class="font-bold">{{ `${dayjs(parseInt(event.startDateTime) * 1000).format("D MMM YYYY")} | ${dayjs(parseInt(event.startDateTime) * 1000).format("H:mm")}` }}</span> to <span class="font-bold">{{ `${dayjs(parseInt(event.endDateTime) * 1000).format("D MMM YYYY")} | ${dayjs(parseInt(event.endDateTime) * 1000).format("H:mm")}` }}</span></span>
Ethan-Chew marked this conversation as resolved.
Show resolved Hide resolved
<span><span class="font-bold">Location:</span> {{ props.event.location }}</span>
</div>
</div>
</div>
</template>
<div class="">
<span class="text-xl font-bold">Description</span>
<p class="text-md mt-2">
{{ props.event.description }}
</p>
</div>
</UCard>
</template>
135 changes: 135 additions & 0 deletions components/admin/event/upload-attendees.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<script setup lang="ts">
import type { ParseResult } from 'papaparse'
import { parse } from 'papaparse'
import { ref } from 'vue'
import { createId } from '@paralleldrive/cuid2'
import type { EventWithAttendees, User } from '~/shared/types'
import { useQueryClient } from '@tanstack/vue-query'

const props = defineProps<{
event: EventWithAttendees
Ethan-Chew marked this conversation as resolved.
Show resolved Hide resolved
}>()
const queryClient = useQueryClient()

let uploadedAttendees: Omit<User, 'firebaseId' | 'id'>[]
const pending = ref(false)
const isUploaded = ref(false)
const error = ref({
didError: false,
Ethan-Chew marked this conversation as resolved.
Show resolved Hide resolved
msg: '',
})
const file = ref<File | undefined>(undefined)

// Check for Type
function isFile(file: File | undefined): file is File {
return file instanceof File
}

function handleFileUpload(e: Event) {
pending.value = true
file.value = (e.target as HTMLInputElement).files?.[0]

// Parse the CSV File
if (isFile(file.value)) {
parse(file.value, {
skipEmptyLines: true,
header: true,
complete(results: ParseResult<Omit<User, 'firebaseId' | 'id'>>) {
uploadedAttendees = results.data

if (JSON.stringify(results.meta.fields?.sort()) !== JSON.stringify(['name', 'email', 'memberType', 'graduationYear'].sort())) {
error.value.didError = true
error.value.msg = 'CSV has missing fields! Please check the file and reupload it.'
return
}

pending.value = uploadedAttendees === undefined
},
})
}
}

async function uploadToDB() {
qin-guan marked this conversation as resolved.
Show resolved Hide resolved
// Upload to users database
const createdUsers = []

for (let i = 0; i < uploadedAttendees.length; i += 30) {
let usersBatch = uploadedAttendees.slice(i, i + 29)

usersBatch = usersBatch.map(item => ({
...item,
graduationYear: Number.parseInt((item.graduationYear).toString(), 10),
memberId: createId(),
}))

try {
const res = await $api('/api/user/bulk', {
method: 'POST',
body: usersBatch,
})

createdUsers.push(res)
}
catch (err) {
error.value.didError = true
error.value.msg = `Failed to push chunk ${i} to the users database.`
}
}
// Upload to users_event database
for (let i = 0; i < createdUsers.length; i += 1) {
const chunk = []
for (let j = 0; j < createdUsers[i].length; j += 1) {
chunk.push({
userId: createdUsers[i][j].id,
eventId: props.event.id,
admissionKey: createId(),
})
}
// Upload the chunk to the database
try {
await $api('/api/event/users', {
method: 'POST',
body: chunk,
})
}
catch (err) {
error.value.didError = true
error.value.msg = `Failed to push chunk ${i} to the users_event database.`
}
}

pending.value = false
if (!error.value.didError)
isUploaded.value = true
queryClient.invalidateQueries({ queryKey: ['events'] })
}
</script>

<template>
<UCard>
<template #header>
<div class="flex flex-col space-y-1">
<span class="font-semibold text-2xl">Upload Attendees</span>
<span class="text-base">Upload a <i>.csv</i> file containing the <span class="font-bold">Name, Email, Member Type and Graduation Year</span> of the attendees attending this event. Do ensure that this file contains headers for <span class="font-bold">all</span> the columns.</span>
</div>
</template>

<div class="space-y-2">
<div class="space-x-5">
<label class="inline-block p-2 px-4 bg-[#007aff]/[0.4] hover:bg-[#007aff]/[0.7] duration-100 rounded-xl cursor-pointer">
<input type="file" accept=".csv" class="hidden" @change="handleFileUpload($event)">
<div class="space-x-2">
<UIcon name="i-heroicons-arrow-up-tray" />
<span class="text-md font-semibold">Upload File</span>
</div>
</label>
<span><span class="font-bold">{{ file ? `Uploaded File:` : '' }}</span> {{ file ? file.name : '' }}</span>
</div>
<UButton v-if="file" tonal class="flex-inline max-w-xs" :loading="pending" :disabled="pending" @click="uploadToDB">
Populate Database
</UButton>
<span v-if="isUploaded">Successfully added users to the database. </span>
<span v-if="error.didError">{{ error.msg }}</span>
</div>
</UCard>
</template>
107 changes: 107 additions & 0 deletions components/admin/home/create-event-popup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { ref } from 'vue'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import type { Event } from '~/shared/types'

const props = defineProps<{
showPopup: boolean
Ethan-Chew marked this conversation as resolved.
Show resolved Hide resolved
}>()
const queryClient = useQueryClient()

const closePopup = defineEmits(['closePopup'])
const state = reactive({
name: '',
description: '',
location: '',
badgeImage: '',
startDateTime: '',
endDateTime: '',
})
const newEventId = ref('')
const inputError = ref('')

const mutation = useMutation({
mutationFn: (newEvent: Omit<Event, 'id'>) => $api('/api/event', {
method: 'POST',
body: newEvent,
}),
})

async function handleSubmit() {
inputError.value = ''

const newEvent = { ...state }
newEvent.startDateTime = dayjs(state.startDateTime).toISOString()
newEvent.endDateTime = dayjs(state.endDateTime).toISOString()

try {
const res = await mutation.mutateAsync(newEvent)

newEventId.value = res.id
queryClient.invalidateQueries({ queryKey: ['events'] })
} catch (err) {
if (err instanceof Error && err.message.includes('Invalid Image URL')) {
inputError.value = "Invalid Image URL"
}
}
}
</script>

<template>
<div>
<UModal v-model="props.showPopup">
<UCard>
<template #header>
<div class="flex flex-row items-start">
<div>
<h1 class="text-xl font-bold pb-1">
Create Event
</h1>
<p class="text-md">
Create a new SSTAA event
</p>
</div>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="ml-auto -my-1" @click="$emit('closePopup')" />
</div>
</template>

<UForm :state="state" class="space-y-7" @submit="handleSubmit">
<div class="space-y-5">
<UFormGroup label="Event Name" required :error="!state.name && 'You must enter an Event Name!'">
Ethan-Chew marked this conversation as resolved.
Show resolved Hide resolved
<UInput v-model="state.name" placeholder="Amazing Event" color="gray" variant="outline" :disabled="newEventId !== ''" />
</UFormGroup>
<UFormGroup label="Description" description="Provide a short description describing what this event is about" required :error="!state.description && 'You must enter an Event Description!'">
<UInput v-model="state.description" placeholder="Description" color="gray" variant="outline" :disabled="newEventId !== ''" />
</UFormGroup>
<UFormGroup label="Location" description="Where is this event held at" required :error="!state.location && 'You must enter the Event Location!'">
<UInput v-model="state.location" placeholder="School of Science and Technology, Singapore" color="gray" variant="outline" :disabled="newEventId !== ''" />
</UFormGroup>
<UFormGroup name="imageurl" label="Banner Image" description="The image shown to everyone viewing this event. Enter a valid Image URL." required :error="!state.badgeImage && 'You must enter a valid Image URL'">
<UInput v-model="state.badgeImage" name="imageurl" type="url" placeholder="https://example.com" color="gray" variant="outline" :disabled="newEventId !== ''" />
</UFormGroup>
<UFormGroup label="Start Date and Time" required :error="!state.startDateTime && 'You must enter the Start Date!'">
<UInput v-model="state.startDateTime" type="datetime-local" color="gray" variant="outline" :disabled="newEventId !== ''" />
</UFormGroup>
<UFormGroup label="End Date and Time" required :error="!state.endDateTime && 'You must enter the End Date!'">
<UInput v-model="state.endDateTime" type="datetime-local" color="gray" variant="outline" :disabled="newEventId !== ''" />
</UFormGroup>
</div>

<UButton v-if="newEventId === ''" type="submit" color="green" :loading="mutation.isLoading.value">
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need .value

Create Event
</UButton>
<p v-if="inputError !== ''">
Ethan-Chew marked this conversation as resolved.
Show resolved Hide resolved
{{ inputError }}
</p>
</UForm>
<div v-if="mutation.isSuccess.value" class="space-y-3 mt-4">
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need .value

<p>Created Event with an ID of <b>{{ newEventId }}</b>.</p>
<UButton color="blue" @click="$emit('closePopup')">
Done
</UButton>
</div>
</UCard>
</UModal>
</div>
</template>
Loading