423 lines
22 KiB
Vue
423 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import { trainingReportSchema, courseEventAttendeeSchema } from '@shared/schemas/trainingReportSchema'
|
|
import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails } from '@shared/types/course'
|
|
import { useForm, useFieldArray, FieldArray as VeeFieldArray, ErrorMessage, Field as VeeField } from 'vee-validate'
|
|
import { toTypedSchema } from '@vee-validate/zod'
|
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
|
import { getAllAttendeeRoles, getAllTrainings, postTrainingReport } from '@/api/trainingReport'
|
|
import { getAllLightMembers, getLightMembers, getMembers } from '@/api/member'
|
|
import { Member, MemberLight } from '@shared/types/member'
|
|
|
|
import FieldGroup from '../ui/field/FieldGroup.vue'
|
|
import Field from '../ui/field/Field.vue'
|
|
import FieldLabel from '../ui/field/FieldLabel.vue'
|
|
import FieldError from '../ui/field/FieldError.vue'
|
|
import Button from '../ui/button/Button.vue'
|
|
import Textarea from '../ui/textarea/Textarea.vue'
|
|
import { Check, Plus, X } from 'lucide-vue-next';
|
|
import FieldSet from '../ui/field/FieldSet.vue'
|
|
import FieldLegend from '../ui/field/FieldLegend.vue'
|
|
import FieldDescription from '../ui/field/FieldDescription.vue'
|
|
import Checkbox from '../ui/checkbox/Checkbox.vue'
|
|
import { configure } from 'vee-validate'
|
|
import { ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox"
|
|
import Popover from "@/components/ui/popover/Popover.vue";
|
|
import PopoverTrigger from "@/components/ui/popover/PopoverTrigger.vue";
|
|
import PopoverContent from "@/components/ui/popover/PopoverContent.vue";
|
|
import Combobox from '../ui/combobox/Combobox.vue'
|
|
|
|
|
|
const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({
|
|
validationSchema: toTypedSchema(trainingReportSchema),
|
|
validateOnMount: false,
|
|
initialValues: {
|
|
course_id: null,
|
|
event_date: "",
|
|
remarks: "",
|
|
attendees: [],
|
|
}
|
|
})
|
|
|
|
// watch(errors, (newErrors) => {
|
|
// console.log(newErrors)
|
|
// }, { deep: true })
|
|
|
|
// watch(values, (newErrors) => {
|
|
// console.log(newErrors.attendees)
|
|
// }, { deep: true })
|
|
|
|
watch(() => values.course_id, (newCourseId, oldCourseId) => {
|
|
if (!oldCourseId) return;
|
|
|
|
values.attendees.forEach((a, index) => {
|
|
setFieldValue(`attendees[${index}].passed_bookwork`, false);
|
|
setFieldValue(`attendees[${index}].passed_qual`, false);
|
|
});
|
|
});
|
|
|
|
const submitForm = handleSubmit(onSubmit);
|
|
|
|
function toMySQLDateTime(date: Date): string {
|
|
return date
|
|
.toISOString() // 2025-11-19T00:00:00.000Z
|
|
.slice(0, 23) // keep milliseconds → 2025-11-19T00:00:00.000
|
|
.replace("T", " ") + "000"; // becomes → 2025-11-19 00:00:00.000000
|
|
}
|
|
|
|
|
|
function onSubmit(vals) {
|
|
try {
|
|
const clean: CourseEventDetails = {
|
|
...vals,
|
|
event_date: new Date(vals.event_date),
|
|
}
|
|
|
|
postTrainingReport(clean).then((newID) => {
|
|
emit("submit", newID);
|
|
});
|
|
} catch (err) {
|
|
console.error("There was an error submitting the training report", err);
|
|
}
|
|
}
|
|
|
|
const { remove, push, fields } = useFieldArray('attendees');
|
|
|
|
const selectedCourse = computed<Course | undefined>(() => { return trainings.value?.find(c => c.id == values.course_id) })
|
|
|
|
const trainings = ref<Course[] | null>(null);
|
|
const members = ref<MemberLight[] | null>(null);
|
|
const eventRoles = ref<CourseAttendeeRole[] | null>(null);
|
|
|
|
const emit = defineEmits(['submit'])
|
|
|
|
onMounted(async () => {
|
|
trainings.value = await getAllTrainings();
|
|
members.value = await getAllLightMembers();
|
|
eventRoles.value = await getAllAttendeeRoles();
|
|
})
|
|
|
|
const selectCourse = ref(false);
|
|
const openMap = reactive<Record<string, boolean>>({})
|
|
|
|
const memberMap = computed(() =>
|
|
Object.fromEntries(
|
|
members.value?.map(m => [m.id, m.displayName || m.username]) ?? []
|
|
)
|
|
)
|
|
|
|
const memberSearch = ref('')
|
|
|
|
const MAX_RESULTS = 50
|
|
|
|
const filteredMembers = computed(() => {
|
|
const q = memberSearch?.value?.toLowerCase() ?? ""
|
|
const results: MemberLight[] = []
|
|
|
|
for (const m of members.value ?? []) {
|
|
if (!q || (m.displayName || m.username).toLowerCase().includes(q)) {
|
|
results.push(m)
|
|
if (results.length >= MAX_RESULTS) break
|
|
}
|
|
}
|
|
|
|
return results
|
|
})
|
|
|
|
</script>
|
|
<template>
|
|
<form id="trainingForm" @submit.prevent="submitForm" class="flex flex-col gap-5">
|
|
|
|
<div class="flex gap-5">
|
|
<div class="flex-1">
|
|
<FieldGroup>
|
|
<VeeField v-slot="{ field, errors }" name="course_id">
|
|
<Field :data-invalid="!!errors.length">
|
|
<FieldLabel class="scroll-m-20 text-lg tracking-tight">Training Course</FieldLabel>
|
|
<Combobox :model-value="field.value" @update:open="selectCourse = $event"
|
|
:open="selectCourse" @update:model-value="(v) => {
|
|
field.onChange(v);
|
|
selectCourse = false
|
|
}" class="w-full">
|
|
<ComboboxAnchor class="w-full">
|
|
<ComboboxInput @focus="selectCourse = true" placeholder="Search courses..."
|
|
class="w-full pl-3" :display-value="(id) => {
|
|
const c = trainings?.find(t => t.id === id)
|
|
return c ? c.name : '';
|
|
}" />
|
|
</ComboboxAnchor>
|
|
|
|
<ComboboxList class="w-full">
|
|
<ComboboxEmpty class="text-muted-foreground w-full">No results</ComboboxEmpty>
|
|
<ComboboxGroup>
|
|
<div class="max-h-80 overflow-y-scroll scrollbar-themed min-w-md">
|
|
<template v-for="course in trainings" :key="course.id">
|
|
<ComboboxItem :value="course.id"
|
|
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5 w-full">
|
|
{{ course.name }}
|
|
<ComboboxItemIndicator
|
|
class="absolute left-2 inline-flex items-center">
|
|
<Check class="h-4 w-4" />
|
|
</ComboboxItemIndicator>
|
|
</ComboboxItem>
|
|
</template>
|
|
</div>
|
|
</ComboboxGroup>
|
|
</ComboboxList>
|
|
</Combobox>
|
|
<div class="h-4">
|
|
<FieldError v-if="errors.length" :errors="errors" />
|
|
</div>
|
|
</Field>
|
|
</VeeField>
|
|
</FieldGroup>
|
|
</div>
|
|
|
|
<div class="w-[150px]">
|
|
<FieldGroup>
|
|
<VeeField v-slot="{ field, errors }" name="event_date">
|
|
<Field :data-invalid="!!errors.length">
|
|
<FieldLabel class="scroll-m-20 text-lg tracking-tight">Event Date</FieldLabel>
|
|
<input type="date" v-bind="field"
|
|
class="h-9 border rounded p-2 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none" />
|
|
<div class="h-4">
|
|
<FieldError v-if="errors.length" :errors="errors" />
|
|
</div>
|
|
</Field>
|
|
</VeeField>
|
|
</FieldGroup>
|
|
</div>
|
|
</div>
|
|
|
|
<VeeFieldArray name="attendees" v-slot="{ fields, push, remove }">
|
|
<FieldSet class="gap-4">
|
|
<div class="flex flex-col gap-2">
|
|
<FieldLegend class="scroll-m-20 text-lg tracking-tight mb-0">Attendees</FieldLegend>
|
|
<FieldDescription class="mb-0">Add members who attended this session.</FieldDescription>
|
|
<div class="h-4">
|
|
<div class="text-red-500 text-sm"
|
|
v-if="errors.attendees && typeof errors.attendees === 'string'">
|
|
{{ errors.attendees }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<FieldGroup class="gap-4">
|
|
|
|
<!-- Column Headers -->
|
|
<div class="relative">
|
|
<div
|
|
class="grid grid-cols-[180px_155px_65px_41px_1fr_auto] gap-3 font-medium text-sm text-muted-foreground">
|
|
|
|
<div>Member</div>
|
|
<div>Role</div>
|
|
|
|
<!-- Bookwork -->
|
|
<div class="text-center">Bookwork</div>
|
|
|
|
<!-- Qual -->
|
|
<div class="text-center">Qual</div>
|
|
|
|
<div>Remarks</div>
|
|
<div></div> <!-- empty for remove button -->
|
|
</div>
|
|
|
|
<!-- FLOATING SUPERHEADER -->
|
|
<div class="absolute left-[calc(180px+155px+65px/2)] -top-5
|
|
w-[106px] text-center text-xs font-medium text-muted-foreground
|
|
pointer-events-none">
|
|
──── Pass ────
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- Attendee Rows -->
|
|
<template v-for="(field, index) in fields" :key="field.key">
|
|
|
|
<div class="grid grid-cols-[180px_160px_50px_50px_1fr_auto] gap-3 items-center">
|
|
|
|
<!-- Member Select -->
|
|
<VeeField :name="`attendees[${index}].attendee_id`" v-slot="{ field: f, errors: e }">
|
|
<div>
|
|
<Combobox :model-value="f.value"
|
|
@update:open="openMap['member-' + field.key] = $event"
|
|
:open="openMap['member-' + field.key]" @update:model-value="(v) => {
|
|
f.onChange(v);
|
|
openMap['member-' + field.key] = false
|
|
}" class="w-full">
|
|
<ComboboxAnchor class="w-full">
|
|
<ComboboxInput
|
|
@focus="() => { openMap['member-' + field.key] = true; memberSearch = memberMap[f.value] }"
|
|
placeholder="Search members..." class="w-full pl-3"
|
|
:display-value="(id) => memberMap[id] || ''"
|
|
@input="memberSearch = $event.target.value" />
|
|
</ComboboxAnchor>
|
|
<ComboboxList class="w-full">
|
|
<ComboboxEmpty class="text-muted-foreground w-full">No results
|
|
</ComboboxEmpty>
|
|
<ComboboxGroup>
|
|
<div class="max-h-80 overflow-y-scroll scrollbar-themed min-w-3xs">
|
|
<template v-for="m in filteredMembers" :key="m.id">
|
|
<ComboboxItem :value="m.id"
|
|
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5 w-full">
|
|
{{ m.displayName || m.username }}
|
|
<ComboboxItemIndicator
|
|
class="absolute left-2 inline-flex items-center">
|
|
<Check class="h-4 w-4" />
|
|
</ComboboxItemIndicator>
|
|
</ComboboxItem>
|
|
</template>
|
|
</div>
|
|
</ComboboxGroup>
|
|
</ComboboxList>
|
|
</Combobox>
|
|
<div class="h-4">
|
|
<FieldError v-if="e.length" :errors="e" />
|
|
</div>
|
|
</div>
|
|
</VeeField>
|
|
|
|
<!-- Role Select -->
|
|
<VeeField :name="`attendees[${index}].attendee_role_id`" v-slot="{ field: f, errors: e }">
|
|
<div>
|
|
<Combobox :model-value="f.value"
|
|
@update:open="openMap['role-' + field.key] = $event"
|
|
:open="openMap['role-' + field.key]" @update:model-value="(v) => {
|
|
f.onChange(v);
|
|
openMap['role-' + field.key] = false
|
|
}" class="w-full">
|
|
<ComboboxAnchor class="w-full">
|
|
<ComboboxInput @focus="openMap['role-' + field.key] = true"
|
|
placeholder="Search roles..." class="w-full pl-3" :display-value="(id) => {
|
|
const er = eventRoles?.find(t => t.id === id)
|
|
return er?.name;
|
|
}" />
|
|
</ComboboxAnchor>
|
|
<ComboboxList class="w-full">
|
|
<ComboboxEmpty class="text-muted-foreground w-full">No results
|
|
</ComboboxEmpty>
|
|
<ComboboxGroup>
|
|
<div class="max-h-80 overflow-y-scroll scrollbar-themed min-w-3xs">
|
|
<template v-for="r in eventRoles" :key="r.id">
|
|
<ComboboxItem :value="r.id"
|
|
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5 w-full">
|
|
<div class="flex justify-between w-full gap-8">
|
|
<p>{{ r.name }}</p>
|
|
<p class="text-muted-foreground">{{ r.description }}</p>
|
|
</div>
|
|
<ComboboxItemIndicator
|
|
class="absolute left-2 inline-flex items-center">
|
|
<Check class="h-4 w-4" />
|
|
</ComboboxItemIndicator>
|
|
</ComboboxItem>
|
|
</template>
|
|
</div>
|
|
</ComboboxGroup>
|
|
</ComboboxList>
|
|
</Combobox>
|
|
|
|
<div class="h-4">
|
|
<FieldError v-if="e.length" :errors="e" />
|
|
</div>
|
|
</div>
|
|
</VeeField>
|
|
|
|
<!-- Bookwork Checkbox -->
|
|
<VeeField v-slot="{ field }" :name="`attendees[${index}].passed_bookwork`" type="checkbox"
|
|
:value="false" :unchecked-value="true">
|
|
<div class="flex flex-col items-center">
|
|
<div class="relative inline-flex items-center group">
|
|
|
|
<Checkbox :disabled="!selectedCourse?.hasBookwork"
|
|
:name="`attendees[${index}].passed_bookwork`" :model-value="!field.checked"
|
|
@update:model-value="field['onUpdate:modelValue']">
|
|
</Checkbox>
|
|
<!-- Tooltip bubble -->
|
|
<div v-if="!selectedCourse?.hasBookwork" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
|
|
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
|
|
text-popover-foreground shadow-md border border-border
|
|
opacity-0 translate-y-1
|
|
group-hover:opacity-100 group-hover:translate-y-0
|
|
transition-opacity transition-transform duration-150">
|
|
This course does not have bookwork
|
|
</div>
|
|
</div>
|
|
<div class="h-4">
|
|
</div>
|
|
</div>
|
|
</VeeField>
|
|
|
|
<!-- Qual Checkbox -->
|
|
<VeeField v-slot="{ field }" :name="`attendees[${index}].passed_qual`" type="checkbox"
|
|
:value="false" :unchecked-value="true">
|
|
<div class="flex flex-col items-center">
|
|
<div class="relative inline-flex items-center group">
|
|
<Checkbox :disabled="!selectedCourse?.hasQual"
|
|
:name="`attendees[${index}].passed_qual`" :model-value="!field.checked"
|
|
@update:model-value="field['onUpdate:modelValue']"></Checkbox>
|
|
<!-- Tooltip bubble -->
|
|
<div v-if="!selectedCourse?.hasQual" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
|
|
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
|
|
text-popover-foreground shadow-md border border-border
|
|
opacity-0 translate-y-1
|
|
group-hover:opacity-100 group-hover:translate-y-0
|
|
transition-opacity transition-transform duration-150">
|
|
This course does not have a qualification
|
|
</div>
|
|
</div>
|
|
<div class="h-4">
|
|
</div>
|
|
</div>
|
|
</VeeField>
|
|
|
|
<!-- Remarks -->
|
|
<VeeField :name="`attendees[${index}].remarks`" v-slot="{ field: f, errors: e }">
|
|
<div class="flex flex-col">
|
|
<textarea v-bind="f"
|
|
class="h-[38px] resize-none border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none"
|
|
placeholder="Optional remarks"></textarea>
|
|
<div class="h-4">
|
|
<FieldError v-if="e.length" :errors="e" />
|
|
</div>
|
|
</div>
|
|
</VeeField>
|
|
|
|
<div>
|
|
<!-- Remove button -->
|
|
<Button type="button" variant="ghost" size="sm" @click="remove(index)"
|
|
class="self-center">
|
|
<X :size="10" />
|
|
</Button>
|
|
<div class="h-4">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</FieldGroup>
|
|
|
|
|
|
<Button type="button" size="sm" variant="outline"
|
|
@click="push({ attendee_id: null, attendee_role_id: null, passed_bookwork: false, passed_qual: false, remarks: '' })">
|
|
<Plus class="mr-1 h-4 w-4" />
|
|
Add Attendee
|
|
</Button>
|
|
</FieldSet>
|
|
</VeeFieldArray>
|
|
|
|
<FieldGroup class="pt-3">
|
|
<VeeField v-slot="{ field, errors }" name="remarks">
|
|
<Field :data-invalid="!!errors.length">
|
|
<FieldLabel class="scroll-m-20 text-lg tracking-tight">Remarks</FieldLabel>
|
|
<Textarea v-bind="field" placeholder="Any remarks about this training event..."
|
|
autocomplete="off" />
|
|
<FieldError v-if="errors.length" :errors="errors" />
|
|
</Field>
|
|
</VeeField>
|
|
</FieldGroup>
|
|
<div class="flex gap-3 justify-end">
|
|
<Button type="button" variant="outline" @click="resetForm">Reset</Button>
|
|
<Button type="submit" form="trainingForm">Submit</Button>
|
|
</div>
|
|
</form>
|
|
</template>
|