200 lines
6.1 KiB
Vue
200 lines
6.1 KiB
Vue
<script setup lang="ts">
|
|
/** Adds a slot to a course, prefilled with the time range selected in the schedule */
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { toast } from 'vue-sonner'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { useAddSlot, useCourses } from '@/services/api/courses'
|
|
import { useTags } from '@/services/api/tags'
|
|
import {
|
|
DAY_END,
|
|
DAY_START,
|
|
WEEKDAYS,
|
|
toApiTime,
|
|
toMinutes,
|
|
type SlotSelection,
|
|
} from '@/utils/timetable'
|
|
|
|
const props = defineProps<{ selection?: SlotSelection }>()
|
|
const open = defineModel<boolean>('open', { required: true })
|
|
|
|
const { t } = useI18n()
|
|
const { data: courses } = useCourses()
|
|
const { data: tags } = useTags('slot')
|
|
const addSlot = useAddSlot()
|
|
|
|
const courseId = ref<number>()
|
|
const tagId = ref<number>()
|
|
const weekday = ref(1)
|
|
const start = ref('')
|
|
const end = ref('')
|
|
const submitted = ref(false)
|
|
|
|
// Reset the form every time the dialog opens, keeping the last course and tag picked
|
|
watch(
|
|
open,
|
|
(isOpen) => {
|
|
if (!isOpen) return
|
|
weekday.value = props.selection?.weekday ?? 1
|
|
start.value = props.selection?.start ?? '08:15'
|
|
end.value = props.selection?.end ?? '10:00'
|
|
if (!courses.value?.some((course) => course.id === courseId.value)) {
|
|
courseId.value = courses.value?.[0]?.id
|
|
}
|
|
tagId.value ??= tags.value[0]?.id
|
|
submitted.value = false
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
// Same rules as the backend, checked here to point at the faulty field
|
|
const errors = computed(() => {
|
|
const startMinutes = toMinutes(start.value)
|
|
const endMinutes = toMinutes(end.value)
|
|
return {
|
|
course: courseId.value === undefined,
|
|
time:
|
|
!start.value ||
|
|
!end.value ||
|
|
startMinutes >= endMinutes ||
|
|
startMinutes < toMinutes(DAY_START) ||
|
|
endMinutes > toMinutes(DAY_END),
|
|
}
|
|
})
|
|
|
|
async function submit() {
|
|
submitted.value = true
|
|
if (errors.value.course || errors.value.time) return
|
|
if (courseId.value === undefined || tagId.value === undefined) return
|
|
|
|
try {
|
|
await addSlot.mutateAsync({
|
|
courseId: courseId.value,
|
|
body: {
|
|
tag_id: tagId.value,
|
|
weekday: weekday.value,
|
|
start_time: toApiTime(start.value),
|
|
end_time: toApiTime(end.value),
|
|
},
|
|
})
|
|
toast.success(t('schedule.slot-created'))
|
|
open.value = false
|
|
} catch (err) {
|
|
toast.error(t('schedule.slot-save-error'), {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
})
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog v-model:open="open">
|
|
<DialogContent class="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{{ $t('schedule.new-slot') }}</DialogTitle>
|
|
<DialogDescription>{{ $t('schedule.new-slot-description') }}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form id="slot-form" class="grid grid-cols-2 gap-4" novalidate @submit.prevent="submit">
|
|
<div class="col-span-2 flex flex-col gap-2">
|
|
<Label for="slot-course">{{ $t('schedule.form.course') }}</Label>
|
|
<Select v-model="courseId">
|
|
<SelectTrigger id="slot-course" class="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="course in courses" :key="course.id" :value="course.id">
|
|
{{ course.name }}
|
|
<span v-if="course.code" class="font-mono text-xs text-muted-foreground">
|
|
{{ course.code }}
|
|
</span>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<Label for="slot-tag">{{ $t('courses.form.tag') }}</Label>
|
|
<Select v-model="tagId">
|
|
<SelectTrigger id="slot-tag" class="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="tag in tags" :key="tag.id" :value="tag.id">
|
|
<span class="size-2 rounded-full" :style="{ backgroundColor: tag.color }" />
|
|
{{ tag.name }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<Label for="slot-weekday">{{ $t('courses.form.weekday') }}</Label>
|
|
<Select v-model="weekday">
|
|
<SelectTrigger id="slot-weekday" class="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="day in WEEKDAYS" :key="day" :value="day">
|
|
{{ $t(`weekdays.${day}`) }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<Label for="slot-start">{{ $t('courses.form.start') }}</Label>
|
|
<Input
|
|
id="slot-start"
|
|
v-model="start"
|
|
type="time"
|
|
:min="DAY_START"
|
|
:max="DAY_END"
|
|
step="300"
|
|
:aria-invalid="submitted && errors.time"
|
|
/>
|
|
</div>
|
|
<div class="flex flex-col gap-2">
|
|
<Label for="slot-end">{{ $t('courses.form.end') }}</Label>
|
|
<Input
|
|
id="slot-end"
|
|
v-model="end"
|
|
type="time"
|
|
:min="DAY_START"
|
|
:max="DAY_END"
|
|
step="300"
|
|
:aria-invalid="submitted && errors.time"
|
|
/>
|
|
</div>
|
|
<p v-if="submitted && errors.time" class="col-span-2 text-sm text-destructive">
|
|
{{ $t('courses.form.errors.slot-time') }}
|
|
</p>
|
|
</form>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
|
|
<Button type="submit" form="slot-form" :disabled="addSlot.isPending.value">
|
|
{{ $t('common.save') }}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|