Training-Report #27
@@ -9,12 +9,12 @@ export const courseEventAttendeeSchema = z.object({
|
||||
|
||||
export const trainingReportSchema = z.object({
|
||||
id: z.number().int().positive().optional(),
|
||||
course_id: z.number().int(),
|
||||
course_id: z.number({ invalid_type_error: "Must select a training" }).int(),
|
||||
event_date: z
|
||||
.string()
|
||||
.refine(
|
||||
(val) => !isNaN(Date.parse(val)),
|
||||
"event_date must be a valid ISO date string"
|
||||
"Must be a valid date"
|
||||
),
|
||||
remarks: z.string().nullable().optional(),
|
||||
attendees: z.array(courseEventAttendeeSchema).default([]),
|
||||
|
||||
8
ui/package-lock.json
generated
8
ui/package-lock.json
generated
@@ -21,7 +21,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.539.0",
|
||||
"pinia": "^3.0.3",
|
||||
"reka-ui": "^2.5.0",
|
||||
"reka-ui": "^2.6.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
@@ -3235,9 +3235,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/reka-ui": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz",
|
||||
"integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.0.tgz",
|
||||
"integrity": "sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.539.0",
|
||||
"pinia": "^3.0.3",
|
||||
"reka-ui": "^2.5.0",
|
||||
"reka-ui": "^2.6.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { trainingReportSchema, courseEventAttendeeSchema } from '@shared/schemas/trainingReportSchema'
|
||||
import { Course } from '@shared/types/course'
|
||||
import { useForm, useFieldArray } from 'vee-validate'
|
||||
import { Course, CourseAttendee } 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,
|
||||
@@ -13,72 +13,85 @@ import {
|
||||
} from '@/components/ui/form'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getAllTrainings } from '@/api/trainingReport'
|
||||
import { Member } from '@/api/member'
|
||||
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 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'
|
||||
|
||||
console.log(trainingReportSchema instanceof z.ZodType)
|
||||
|
||||
const form = useForm({ validationSchema: toTypedSchema(trainingReportSchema) });
|
||||
|
||||
const { fields: attendeeFields, push, remove } = useFieldArray({
|
||||
name: 'attendees',
|
||||
const { handleSubmit, resetForm } = useForm({
|
||||
validationSchema: toTypedSchema(trainingReportSchema),
|
||||
initialValues: {
|
||||
course_id: null,
|
||||
event_date: "",
|
||||
remarks: "",
|
||||
}
|
||||
})
|
||||
|
||||
function onSubmit(vals) {
|
||||
console.log(vals);
|
||||
// TODO: move this date conversion to a date library
|
||||
const clean = {
|
||||
...vals,
|
||||
event_date: new Date(vals.event_date).toISOString(),
|
||||
}
|
||||
|
||||
console.log("SUBMITTED:", clean)
|
||||
}
|
||||
|
||||
import z from 'zod'
|
||||
const schema = z.object({ x: z.string() })
|
||||
const typed = toTypedSchema(schema)
|
||||
console.log(typed)
|
||||
|
||||
|
||||
const trainings = ref<Course[] | null>(null);
|
||||
const members = ref<Member[] | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
trainings.value = await getAllTrainings();
|
||||
members.value = await getMembers();
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<form @submit="form.handleSubmit(onSubmit)">
|
||||
<form id="trainingForm" @submit.prevent="handleSubmit(onSubmit)" class="flex flex-col gap-5">
|
||||
<FieldGroup>
|
||||
<VeeField v-slot="{ field, errors }" name="course_id">
|
||||
<Field :data-invalid="!!errors.length">
|
||||
<FieldLabel>Training Course</FieldLabel>
|
||||
|
||||
<!-- Training report fields here -->
|
||||
<select v-bind="field" class="border rounded p-2 w-full">
|
||||
<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="my-6">
|
||||
<h2 class="font-semibold text-lg mb-4">Attendees</h2>
|
||||
<FieldError v-if="errors.length" :errors="errors" />
|
||||
</Field>
|
||||
</VeeField>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<VeeField v-slot="{ field, errors }" name="event_date">
|
||||
<Field :data-invalid="!!errors.length">
|
||||
<FieldLabel>Event Date</FieldLabel>
|
||||
|
||||
<div v-for="(field, idx) in attendeeFields" :key="field.key" class="mb-4 p-4 border rounded">
|
||||
<input type="date" v-bind="field" class="border rounded p-2 w-full" />
|
||||
|
||||
<!-- attendee_id -->
|
||||
<FormField name="attendees[idx].attendee_id" v-slot="{ componentField }">
|
||||
<FormItem>
|
||||
<FormLabel>Attendee ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- passed -->
|
||||
<FormField name="attendees[idx].passed" v-slot="{ componentField }">
|
||||
<FormItem>
|
||||
<FormLabel>Passed</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button variant="destructive" @click.prevent="remove(idx)">Remove</Button>
|
||||
</div>
|
||||
|
||||
<Button @click.prevent="push({ attendee_id: null, attendee_role_id: null, passed: false, remarks: null })">
|
||||
Add Attendee
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button type="submit">Submit Report</Button>
|
||||
<FieldError v-if="errors.length" :errors="errors" />
|
||||
</Field>
|
||||
</VeeField>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<VeeField v-slot="{ field, errors }" name="remarks">
|
||||
<Field :data-invalid="!!errors.length">
|
||||
<FieldLabel>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>
|
||||
<Field orientation="horizontal">
|
||||
<Button type="button" variant="outline" @click="resetForm">Reset</Button>
|
||||
<Button type="submit" form="trainingForm">Submit</Button>
|
||||
</Field>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
20
ui/src/components/ui/field/Field.vue
Normal file
20
ui/src/components/ui/field/Field.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fieldVariants } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
orientation: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
:data-orientation="orientation"
|
||||
:class="cn(fieldVariants({ orientation }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
ui/src/components/ui/field/FieldContent.vue
Normal file
21
ui/src/components/ui/field/FieldContent.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-content"
|
||||
:class="
|
||||
cn(
|
||||
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
23
ui/src/components/ui/field/FieldDescription.vue
Normal file
23
ui/src/components/ui/field/FieldDescription.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="field-description"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
56
ui/src/components/ui/field/FieldError.vue
Normal file
56
ui/src/components/ui/field/FieldError.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
errors: { type: Array, required: false },
|
||||
});
|
||||
|
||||
const content = computed(() => {
|
||||
if (!props.errors || props.errors.length === 0) return null;
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(
|
||||
props.errors.filter(Boolean).map((error) => {
|
||||
const message = typeof error === "string" ? error : error?.message;
|
||||
return [message, error];
|
||||
}),
|
||||
).values(),
|
||||
];
|
||||
|
||||
if (uniqueErrors.length === 1 && uniqueErrors[0]) {
|
||||
return typeof uniqueErrors[0] === "string"
|
||||
? uniqueErrors[0]
|
||||
: uniqueErrors[0].message;
|
||||
}
|
||||
|
||||
return uniqueErrors.map((error) =>
|
||||
typeof error === "string" ? error : error?.message,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="$slots.default || content"
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
:class="cn('text-destructive text-sm font-normal', props.class)"
|
||||
>
|
||||
<slot v-if="$slots.default" />
|
||||
|
||||
<template v-else-if="typeof content === 'string'">
|
||||
{{ content }}
|
||||
</template>
|
||||
|
||||
<ul
|
||||
v-else-if="Array.isArray(content)"
|
||||
class="ml-4 flex list-disc flex-col gap-1"
|
||||
>
|
||||
<li v-for="(error, index) in content" :key="index">
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
21
ui/src/components/ui/field/FieldGroup.vue
Normal file
21
ui/src/components/ui/field/FieldGroup.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-group"
|
||||
:class="
|
||||
cn(
|
||||
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
24
ui/src/components/ui/field/FieldLabel.vue
Normal file
24
ui/src/components/ui/field/FieldLabel.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
:class="
|
||||
cn(
|
||||
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
|
||||
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
25
ui/src/components/ui/field/FieldLegend.vue
Normal file
25
ui/src/components/ui/field/FieldLegend.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
variant: { type: String, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
:data-variant="variant"
|
||||
:class="
|
||||
cn(
|
||||
'mb-3 font-medium',
|
||||
'data-[variant=legend]:text-base',
|
||||
'data-[variant=label]:text-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</legend>
|
||||
</template>
|
||||
30
ui/src/components/ui/field/FieldSeparator.vue
Normal file
30
ui/src/components/ui/field/FieldSeparator.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
:data-content="!!$slots.default"
|
||||
:class="
|
||||
cn(
|
||||
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<Separator class="absolute inset-0 top-1/2" />
|
||||
<span
|
||||
v-if="$slots.default"
|
||||
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
22
ui/src/components/ui/field/FieldSet.vue
Normal file
22
ui/src/components/ui/field/FieldSet.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col gap-6',
|
||||
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</fieldset>
|
||||
</template>
|
||||
21
ui/src/components/ui/field/FieldTitle.vue
Normal file
21
ui/src/components/ui/field/FieldTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-label"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
36
ui/src/components/ui/field/index.js
Normal file
36
ui/src/components/ui/field/index.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export { default as Field } from "./Field.vue";
|
||||
export { default as FieldContent } from "./FieldContent.vue";
|
||||
export { default as FieldDescription } from "./FieldDescription.vue";
|
||||
export { default as FieldError } from "./FieldError.vue";
|
||||
export { default as FieldGroup } from "./FieldGroup.vue";
|
||||
export { default as FieldLabel } from "./FieldLabel.vue";
|
||||
export { default as FieldLegend } from "./FieldLegend.vue";
|
||||
export { default as FieldSeparator } from "./FieldSeparator.vue";
|
||||
export { default as FieldSet } from "./FieldSet.vue";
|
||||
export { default as FieldTitle } from "./FieldTitle.vue";
|
||||
@@ -53,7 +53,7 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto flex mt-5">
|
||||
<!-- training report list -->
|
||||
<div class="px-4" :class="focusedTrainingReport == null ? 'w-full' : 'w-1/2'">
|
||||
<div class="px-4" :class="sidePanel == sidePanelState.closed ? 'w-full' : 'w-1/2'">
|
||||
<div class="flex justify-between">
|
||||
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">Training Reports</p>
|
||||
<Button @click="createTrainingReport">New Training Report</Button>
|
||||
@@ -117,7 +117,15 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sidePanel == sidePanelState.create" class="px-4 border-l w-1/2">
|
||||
<TrainingReportForm></TrainingReportForm>
|
||||
<div class="flex justify-between my-3">
|
||||
<div class="flex gap-5">
|
||||
<p>New Training Report</p>
|
||||
</div>
|
||||
<button @click="closeTrainingReport" class="cursor-pointer">
|
||||
<X></X>
|
||||
</button>
|
||||
</div>
|
||||
<TrainingReportForm class="w-full"></TrainingReportForm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -46,13 +46,13 @@ router.beforeEach(async (to) => {
|
||||
await user.loadUser();
|
||||
}
|
||||
|
||||
// Not logged in
|
||||
if (to.meta.requiresAuth && !user.isLoggedIn) {
|
||||
// Redirect back to original page after login
|
||||
const redirectUrl = encodeURIComponent(window.location.origin + to.fullPath)
|
||||
window.location.href = `https://aj17thdevapi.nexuszone.net/login?redirect=${redirectUrl}`
|
||||
return false // Prevent Vue Router from continuing
|
||||
}
|
||||
// // Not logged in
|
||||
// if (to.meta.requiresAuth && !user.isLoggedIn) {
|
||||
// // Redirect back to original page after login
|
||||
// const redirectUrl = encodeURIComponent(window.location.origin + to.fullPath)
|
||||
// window.location.href = `https://aj17thdevapi.nexuszone.net/login?redirect=${redirectUrl}`
|
||||
// return false // Prevent Vue Router from continuing
|
||||
// }
|
||||
|
||||
|
||||
// // Must be a member
|
||||
|
||||
Reference in New Issue
Block a user