298 lines
15 KiB
Vue
298 lines
15 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, onMounted, ref, watch } from 'vue'
|
|
import { getAllAttendeeRoles, getAllTrainings, postTrainingReport } from '@/api/trainingReport'
|
|
import { getMembers, Member } from '@/api/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 { 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'
|
|
|
|
|
|
const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({
|
|
validationSchema: toTypedSchema(trainingReportSchema),
|
|
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: toMySQLDateTime(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<Member[] | null>(null);
|
|
const eventRoles = ref<CourseAttendeeRole[] | null>(null);
|
|
|
|
const emit = defineEmits(['submit'])
|
|
|
|
onMounted(async () => {
|
|
trainings.value = await getAllTrainings();
|
|
members.value = await getMembers();
|
|
eventRoles.value = await getAllAttendeeRoles();
|
|
})
|
|
</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>
|
|
<select v-bind="field"
|
|
class="h-9 border rounded p-2 w-auto focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none">
|
|
<option value="" disabled>Select a course</option>
|
|
<option v-for="course in trainings" :key="course.id" :value="course.id">
|
|
{{ course.name }}
|
|
</option>
|
|
</select>
|
|
<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">
|
|
<FieldLegend class="scroll-m-20 text-lg tracking-tight">Attendees</FieldLegend>
|
|
<FieldDescription>Add members who attended this session.</FieldDescription>
|
|
|
|
<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>
|
|
<select v-bind="f"
|
|
class="border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none">
|
|
<option value="">Select member...</option>
|
|
<option v-for="m in members" :key="m.member_id" :value="m.member_id">
|
|
{{ m.member_name }}
|
|
</option>
|
|
</select>
|
|
<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>
|
|
<select v-bind="f"
|
|
class="border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none">
|
|
<option value="">Select role...</option>
|
|
<option v-for="r in eventRoles" :key="r.id" :value="r.id">
|
|
{{ r.name }}
|
|
</option>
|
|
</select>
|
|
<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>
|