Training-Report #27
@@ -1,5 +1,5 @@
|
||||
import { CourseAttendee, CourseEventDetails } from "@app/shared/types/course";
|
||||
import { getAllCourses, getCourseEventAttendees, getCourseEventDetails, getCourseEvents, insertCourseEvent } from "../services/CourseSerivce";
|
||||
import { getAllCourses, getCourseEventAttendees, getCourseEventDetails, getCourseEventRoles, getCourseEvents, insertCourseEvent } from "../services/CourseSerivce";
|
||||
import { Request, Response, Router } from "express";
|
||||
|
||||
const courseRouter = Router();
|
||||
@@ -15,6 +15,16 @@ courseRouter.get('/', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
courseRouter.get('/roles', async (req, res) => {
|
||||
try {
|
||||
const roles = await getCourseEventRoles();
|
||||
res.status(200).json(roles);
|
||||
} catch (err) {
|
||||
console.error('failed to fetch course roles', err);
|
||||
res.status(500).json('failed to fetch course roles\n' + err);
|
||||
}
|
||||
})
|
||||
|
||||
eventRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
let events = await getCourseEvents();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import pool from "../db"
|
||||
import { Course, CourseAttendee, CourseEventDetails, CourseEventSummary, RawAttendeeRow } from "@app/shared/types/course"
|
||||
import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails, CourseEventSummary, RawAttendeeRow } from "@app/shared/types/course"
|
||||
|
||||
export async function getAllCourses(): Promise<Course[]> {
|
||||
const sql = "SELECT * FROM courses WHERE deleted = false;"
|
||||
@@ -91,4 +91,10 @@ export async function getCourseEvents(): Promise<CourseEventSummary[]> {
|
||||
const sql = "SELECT E.id AS event_id, E.course_id, E.event_date AS date, E.created_by, C.name AS course_name FROM course_events as E LEFT JOIN courses AS C ON E.course_id = C.id;";
|
||||
let events: CourseEventSummary[] = await pool.query(sql);
|
||||
return events;
|
||||
}
|
||||
|
||||
export async function getCourseEventRoles(): Promise<CourseAttendeeRole[]> {
|
||||
const sql = "SELECT * FROM course_attendee_roles;"
|
||||
const roles: CourseAttendeeRole[] = await pool.query(sql);
|
||||
return roles;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Course, CourseEventDetails, CourseEventSummary } from '@shared/types/course'
|
||||
import { Course, CourseAttendeeRole, CourseEventDetails, CourseEventSummary } from '@shared/types/course'
|
||||
|
||||
//@ts-ignore
|
||||
const addr = import.meta.env.VITE_APIHOST;
|
||||
@@ -29,9 +29,20 @@ export async function getAllTrainings(): Promise<Course[]> {
|
||||
const res = await fetch(`${addr}/course`);
|
||||
|
||||
if (res.ok) {
|
||||
return await res.json() as Promise<Course[]>;
|
||||
return await res.json() as Promise<Course[]>;
|
||||
} else {
|
||||
console.error("Something went wrong");
|
||||
throw new Error("Failed to load training list");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllAttendeeRoles(): Promise<CourseAttendeeRole[]> {
|
||||
const res = await fetch(`${addr}/course/roles`);
|
||||
|
||||
if (res.ok) {
|
||||
return await res.json() as Promise<CourseAttendeeRole[]>;
|
||||
} else {
|
||||
console.error("Something went wrong");
|
||||
throw new Error("Failed to load attendee roles");
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { trainingReportSchema, courseEventAttendeeSchema } from '@shared/schemas/trainingReportSchema'
|
||||
import { Course, CourseAttendee } from '@shared/types/course'
|
||||
import { Course, CourseAttendee, CourseAttendeeRole } from '@shared/types/course'
|
||||
import { useForm, useFieldArray, FieldArray as VeeFieldArray, ErrorMessage, Field as VeeField } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getAllTrainings } from '@/api/trainingReport'
|
||||
import { getAllAttendeeRoles, getAllTrainings } from '@/api/trainingReport'
|
||||
import { getMembers, Member } from '@/api/member'
|
||||
import FieldGroup from '../ui/field/FieldGroup.vue'
|
||||
import Field from '../ui/field/Field.vue'
|
||||
@@ -21,16 +13,23 @@ import Input from '../ui/input/Input.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'
|
||||
|
||||
const { handleSubmit, resetForm } = useForm({
|
||||
const { handleSubmit, resetForm, errors } = useForm({
|
||||
validationSchema: toTypedSchema(trainingReportSchema),
|
||||
initialValues: {
|
||||
course_id: null,
|
||||
event_date: "",
|
||||
remarks: "",
|
||||
attendees: [],
|
||||
}
|
||||
})
|
||||
|
||||
const submitForm = handleSubmit(onSubmit);
|
||||
|
||||
function onSubmit(vals) {
|
||||
// TODO: move this date conversion to a date library
|
||||
const clean = {
|
||||
@@ -41,16 +40,20 @@ function onSubmit(vals) {
|
||||
console.log("SUBMITTED:", clean)
|
||||
}
|
||||
|
||||
const { remove, push, fields } = useFieldArray('attendees');
|
||||
|
||||
const trainings = ref<Course[] | null>(null);
|
||||
const members = ref<Member[] | null>(null);
|
||||
const eventRoles = ref<CourseAttendeeRole[] | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
trainings.value = await getAllTrainings();
|
||||
members.value = await getMembers();
|
||||
eventRoles.value = await getAllAttendeeRoles();
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<form id="trainingForm" @submit.prevent="handleSubmit(onSubmit)" class="flex flex-col gap-5">
|
||||
<form id="trainingForm" @submit.prevent="submitForm" class="flex flex-col gap-5">
|
||||
<FieldGroup>
|
||||
<VeeField v-slot="{ field, errors }" name="course_id">
|
||||
<Field :data-invalid="!!errors.length">
|
||||
@@ -71,14 +74,94 @@ onMounted(async () => {
|
||||
<VeeField v-slot="{ field, errors }" name="event_date">
|
||||
<Field :data-invalid="!!errors.length">
|
||||
<FieldLabel>Event Date</FieldLabel>
|
||||
|
||||
<input type="date" v-bind="field" class="border rounded p-2 w-full" />
|
||||
|
||||
<FieldError v-if="errors.length" :errors="errors" />
|
||||
</Field>
|
||||
</VeeField>
|
||||
</FieldGroup>
|
||||
|
||||
|
||||
<VeeFieldArray name="attendees" v-slot="{ fields, push, remove }">
|
||||
<FieldSet class="gap-4">
|
||||
<FieldLegend>Attendees</FieldLegend>
|
||||
<FieldDescription>Add members who attended this session.</FieldDescription>
|
||||
|
||||
<FieldGroup class="gap-4">
|
||||
|
||||
<!-- Column Headers -->
|
||||
<div
|
||||
class="grid grid-cols-[180px_140px_50px_1fr_auto] gap-3 font-medium text-sm text-muted-foreground px-1">
|
||||
<div>Member</div>
|
||||
<div>Role</div>
|
||||
<div>Passed</div>
|
||||
<div>Remarks</div>
|
||||
<div></div> <!-- empty for remove button -->
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Attendee Rows -->
|
||||
<template v-for="(field, index) in fields" :key="field.key">
|
||||
|
||||
<div class="grid grid-cols-[180px_140px_50px_1fr_auto] gap-3 items-start">
|
||||
|
||||
<!-- Member Select -->
|
||||
<VeeField :name="`attendees[${index}].attendee_id`" v-slot="{ field: f, errors: e }">
|
||||
<div>
|
||||
<select v-bind="f" class="w-full border p-2 rounded-md">
|
||||
<option value="">Select member...</option>
|
||||
<option v-for="m in members" :key="m.member_id" :value="m.member_id">
|
||||
{{ m.member_name }}
|
||||
</option>
|
||||
</select>
|
||||
<FieldError v-if="e.length" :errors="e" />
|
||||
</div>
|
||||
</VeeField>
|
||||
|
||||
<!-- Role Select -->
|
||||
<VeeField :name="`attendees[${index}].attendee_role_id`" v-slot="{ field: f, errors: e }">
|
||||
<div>
|
||||
<select v-bind="f" class="w-full border p-2 rounded-md">
|
||||
<option value="">Select role...</option>
|
||||
<option v-for="r in eventRoles" :key="r.id" :value="r.id">
|
||||
{{ r.name }}
|
||||
</option>
|
||||
</select>
|
||||
<FieldError v-if="e.length" :errors="e" />
|
||||
</div>
|
||||
</VeeField>
|
||||
|
||||
<!-- Passed Checkbox -->
|
||||
<VeeField :name="`attendees[${index}].passed`" v-slot="{ field: f, errors: e }">
|
||||
<div class="flex items-center h-[38px]">
|
||||
<input type="checkbox" class="h-4 w-4" v-bind="f" />
|
||||
</div>
|
||||
</VeeField>
|
||||
|
||||
<!-- Remarks -->
|
||||
<VeeField :name="`attendees[${index}].remarks`" v-slot="{ field: f, errors: e }">
|
||||
<div>
|
||||
<textarea v-bind="f" class="w-full border p-2 rounded-md h-[38px] resize-none"
|
||||
placeholder="Optional remarks"></textarea>
|
||||
<FieldError v-if="e.length" :errors="e" />
|
||||
</div>
|
||||
</VeeField>
|
||||
|
||||
<!-- Remove button -->
|
||||
<Button type="button" variant="ghost" size="xs" @click="remove(index)" class="self-center">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldGroup>
|
||||
|
||||
|
||||
<Button type="button" size="sm" variant="outline"
|
||||
@click="push({ attendee_id: null, attendee_role_id: null, passed: false, remarks: '' })">
|
||||
<Plus class="mr-1 h-4 w-4" />
|
||||
Add Attendee
|
||||
</Button>
|
||||
</FieldSet>
|
||||
</VeeFieldArray>
|
||||
|
||||
<FieldGroup>
|
||||
<VeeField v-slot="{ field, errors }" name="remarks">
|
||||
<Field :data-invalid="!!errors.length">
|
||||
|
||||
36
ui/src/components/ui/input-group/InputGroup.vue
Normal file
36
ui/src/components/ui/input-group/InputGroup.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
:class="
|
||||
cn(
|
||||
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
|
||||
'h-9 min-w-0 has-[>textarea]:h-auto',
|
||||
|
||||
// Variants based on alignment.
|
||||
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
|
||||
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
|
||||
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
|
||||
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
|
||||
|
||||
// Focus state.
|
||||
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
|
||||
|
||||
// Error state.
|
||||
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
|
||||
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
32
ui/src/components/ui/input-group/InputGroupAddon.vue
Normal file
32
ui/src/components/ui/input-group/InputGroupAddon.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { inputGroupAddonVariants } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
align: { type: null, required: false, default: "inline-start" },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
function handleInputGroupAddonClick(e) {
|
||||
const currentTarget = e.currentTarget;
|
||||
const target = e.target;
|
||||
if (target && target.closest("button")) {
|
||||
return;
|
||||
}
|
||||
if (currentTarget && currentTarget?.parentElement) {
|
||||
currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
:data-align="props.align"
|
||||
:class="cn(inputGroupAddonVariants({ align: props.align }), props.class)"
|
||||
@click="handleInputGroupAddonClick"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
47
ui/src/components/ui/input-group/index.js
Normal file
47
ui/src/components/ui/input-group/index.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export { default as InputGroup } from "./InputGroup.vue";
|
||||
export { default as InputGroupAddon } from "./InputGroupAddon.vue";
|
||||
export { default as InputGroupButton } from "./InputGroupButton.vue";
|
||||
export { default as InputGroupInput } from "./InputGroupInput.vue";
|
||||
export { default as InputGroupText } from "./InputGroupText.vue";
|
||||
export { default as InputGroupTextarea } from "./InputGroupTextarea.vue";
|
||||
|
||||
export const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user