overhauled form system to new modern form

This commit is contained in:
2025-12-11 10:25:29 -05:00
parent 62defe5b6d
commit 92c0d657ea
3 changed files with 228 additions and 119 deletions

View File

@@ -15,12 +15,6 @@ router.post("/", async (req: Request, res: Response) => {
console.log(LOARequest);
try {
// const result = await pool.query(
// `INSERT INTO leave_of_absences
// (member_id, filed_date, start_date, end_date, reason)
// VALUES (?, ?, ?, ?, ?)`,
// [member_id, filed_date, start_date, end_date, reason]
// );
await createNewLOA(LOARequest);
res.sendStatus(201);
} catch (error) {

View File

@@ -0,0 +1,51 @@
import * as z from "zod";
import { LOAType } from "../types/loa";
export const loaTypeSchema = z.object({
id: z.number(),
name: z.string(),
max_length_days: z.number(),
});
export const loaSchema = z.object({
member_id: z.number(),
start_date: z.date(),
end_date: z.date(),
type: loaTypeSchema,
reason: z.string(),
})
.superRefine((data, ctx) => {
const { start_date, end_date, type } = data;
const today = new Date();
today.setHours(0, 0, 0, 0);
if (start_date < today) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["start_date"],
message: "Start date cannot be in the past.",
});
}
// 1. end > start
if (end_date <= start_date) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["end_date"],
message: "End date must be after start date.",
});
}
// 2. calculate max
const maxEnd = new Date(start_date);
maxEnd.setDate(maxEnd.getDate() + type.max_length_days);
if (end_date > maxEnd) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["end_date"],
message: `This LOA type allows a maximum of ${type.max_length_days} days.`,
});
}
});

View File

@@ -1,13 +1,15 @@
<script setup lang="ts">
import { Check, Search } from "lucide-vue-next"
import { Combobox, ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox"
import { onMounted, ref } from "vue";
import { ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox"
import { onMounted, ref, watch } from "vue";
import { Member, getMembers } from "@/api/member";
import Button from "@/components/ui/button/Button.vue";
import {
CalendarDate,
DateFormatter,
fromDate,
getLocalTimeZone,
parseDate,
} from "@internationalized/date"
import type { DateRange } from "reka-ui"
import type { Ref } from "vue"
@@ -18,10 +20,27 @@ import { RangeCalendar } from "@/components/ui/range-calendar"
import { cn } from "@/lib/utils";
import { CalendarIcon } from "lucide-vue-next"
import Textarea from "@/components/ui/textarea/Textarea.vue";
import { submitLOA } from "@/api/loa"; // <-- import the submit function
import { LOARequest } from "@shared/types/loa";
import { getLoaTypes, submitLOA } from "@/api/loa"; // <-- import the submit function
import { LOARequest, LOAType } from "@shared/types/loa";
import { useForm, Field as VeeField } from "vee-validate";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from '@/components/ui/field'
import Combobox from "../ui/combobox/Combobox.vue";
import Select from "../ui/select/Select.vue";
import SelectTrigger from "../ui/select/SelectTrigger.vue";
import SelectValue from "../ui/select/SelectValue.vue";
import SelectContent from "../ui/select/SelectContent.vue";
import SelectItem from "../ui/select/SelectItem.vue";
import FieldError from "../ui/field/FieldError.vue";
const members = ref<Member[]>([])
const loaTypes = ref<LOAType[]>();
const currentMember = ref<Member | null>(null);
const props = withDefaults(defineProps<{
@@ -32,73 +51,40 @@ const props = withDefaults(defineProps<{
member: null,
});
const df = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const df = new DateFormatter("en-US", {
dateStyle: "medium",
//form stuff
import { loaSchema } from '@shared/schemas/loaSchema'
import { toTypedSchema } from "@vee-validate/zod";
import Calendar from "../ui/calendar/Calendar.vue";
const { handleSubmit, values, resetForm } = useForm({
validationSchema: toTypedSchema(loaSchema),
})
const value = ref({
// start: new CalendarDate(2022, 1, 20),
// end: new CalendarDate(2022, 1, 20).add({ days: 20 }),
}) as Ref<DateRange>
watch(values, (v) => {
console.log("Form values:", v)
})
const reason = ref(""); // <-- reason for LOA
const submitting = ref(false);
const submitError = ref<string | null>(null);
const submitSuccess = ref(false);
const onSubmit = handleSubmit((values) => {
console.log(values);
})
onMounted(async () => {
if (props.member) {
currentMember.value = props.member;
}
if (props.adminMode) {
members.value = await getMembers();
}
members.value = await getMembers();
loaTypes.value = await getLoaTypes();
console.log(currentMember.value);
resetForm({ values: { member_id: currentMember.value?.member_id } });
});
// Submit handler
async function handleSubmit() {
submitError.value = null;
submitSuccess.value = false;
submitting.value = true;
// Use currentMember if adminMode, otherwise use your own member id (stubbed as 89 here)
const member_id = currentMember.value?.member_id ?? 89;
// Format dates as ISO strings
const filed_date = toMariaDBDatetime(new Date());
const start_date = toMariaDBDatetime(value.value.start?.toDate(getLocalTimeZone()));
const end_date = toMariaDBDatetime(value.value.end?.toDate(getLocalTimeZone()));
if (!member_id || !filed_date || !start_date || !end_date) {
submitError.value = "Missing required fields";
submitting.value = false;
return;
}
const req: LOARequest = {
filed_date,
start_date,
end_date,
reason: reason.value,
member_id
};
const result = await submitLOA(req);
submitting.value = false;
if (result.id) {
submitSuccess.value = true;
reason.value = "";
} else {
submitError.value = result.error || "Failed to submit LOA";
}
}
function toMariaDBDatetime(date: Date): string {
return date.toISOString().slice(0, 19).replace('T', ' ');
}
</script>
<template>
@@ -114,60 +100,138 @@ function toMariaDBDatetime(date: Date): string {
</div>
</div>
<div class="flex-1 flex flex-col gap-5">
<div class="flex w-full gap-5 ">
<Combobox class="w-1/2" v-model="currentMember" :disabled="!adminMode">
<ComboboxAnchor class="w-full">
<ComboboxInput placeholder="Search members..." class="w-full pl-9"
:display-value="(v) => v ? v.member_name : ''" />
</ComboboxAnchor>
<ComboboxList class="w-full">
<ComboboxEmpty class="text-muted-foreground">No results</ComboboxEmpty>
<ComboboxGroup>
<template v-for="member in members" :key="member.member_id">
<ComboboxItem :value="member"
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5">
{{ member.member_name }}
<ComboboxItemIndicator class="absolute left-2 inline-flex items-center">
<Check class="h-4 w-4" />
</ComboboxItemIndicator>
</ComboboxItem>
</template>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" :class="cn(
'w-1/2 justify-start text-left font-normal',
!value && 'text-muted-foreground',
)">
<CalendarIcon class="mr-2 h-4 w-4" />
<template v-if="value.start">
<template v-if="value.end">
{{ df.format(value.start.toDate(getLocalTimeZone())) }} - {{
df.format(value.end.toDate(getLocalTimeZone())) }}
</template>
<template v-else>
{{ df.format(value.start.toDate(getLocalTimeZone())) }}
</template>
</template>
<template v-else>
Pick a date
</template>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<RangeCalendar v-model="value" initial-focus :number-of-months="2"
@update:start-value="(startDate) => value.start = startDate" />
</PopoverContent>
</Popover>
</div>
<Textarea v-model="reason" placeholder="Reason for LOA" class="w-full resize-none" />
<div class="flex justify-end">
<Button :onClick="handleSubmit" :disabled="submitting" class="w-min">Submit</Button>
</div>
<div v-if="submitError" class="text-red-500 text-sm mt-2">{{ submitError }}</div>
<div v-if="submitSuccess" class="text-green-500 text-sm mt-2">LOA submitted successfully!</div>
<form @submit="onSubmit" class="flex flex-col gap-2">
<div class="flex w-full gap-5">
<VeeField v-slot="{ field, errors }" name="member_id">
<Field>
<FieldContent>
<FieldLabel>Member</FieldLabel>
<Combobox :model-value="field.value" @update:model-value="field.onChange"
:disabled="!adminMode">
<ComboboxAnchor class="w-full">
<ComboboxInput placeholder="Search members..." class="w-full pl-3"
:display-value="(id) => {
const m = members.find(mem => mem.member_id === id)
return m ? m.member_name : ''
}" />
</ComboboxAnchor>
<ComboboxList class="*:w-64">
<ComboboxEmpty class="text-muted-foreground w-full">No results</ComboboxEmpty>
<ComboboxGroup>
<template v-for="member in members" :key="member.member_id">
<ComboboxItem :value="member.member_id"
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5 w-full">
{{ member.member_name }}
<ComboboxItemIndicator
class="absolute left-2 inline-flex items-center">
<Check class="h-4 w-4" />
</ComboboxItemIndicator>
</ComboboxItem>
</template>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors"></FieldError>
</div>
</FieldContent>
</Field>
</VeeField>
<VeeField v-slot="{ field, errors }" name="type">
<Field class="w-full">
<FieldContent>
<FieldLabel>Type</FieldLabel>
<Select :model-value="field.value" @update:model-value="field.onChange">
<SelectTrigger class="w-full">
<SelectValue></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="type in loaTypes" :value="type">
{{ type.name }}
</SelectItem>
</SelectContent>
</Select>
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors"></FieldError>
</div>
</FieldContent>
</Field>
</VeeField>
</div>
<div class="flex gap-5">
<VeeField v-slot="{ field, errors }" name="start_date">
<Field>
<FieldContent>
<FieldLabel>Start Date</FieldLabel>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" :class="cn(
'w-[280px] justify-start text-left font-normal',
!field.value && 'text-muted-foreground',
)">
<CalendarIcon class="mr-2 h-4 w-4" />
{{ field.value ? df.format(field.value) : "Pick a date" }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar
:model-value="field.value ? fromDate(field.value, getLocalTimeZone()) : null"
@update:model-value="(val: CalendarDate) => field.onChange(val.toDate(getLocalTimeZone()))"
layout="month-and-year" />
</PopoverContent>
</Popover>
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors"></FieldError>
</div>
</FieldContent>
</Field>
</VeeField>
<VeeField v-slot="{ field, errors }" name="end_date">
<Field>
<FieldContent>
<FieldLabel>End Date</FieldLabel>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" :class="cn(
'w-[280px] justify-start text-left font-normal',
!field.value && 'text-muted-foreground',
)">
<CalendarIcon class="mr-2 h-4 w-4" />
{{ field.value ? df.format(field.value) : "Pick a date" }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar
:model-value="field.value ? new CalendarDate(field.value.getFullYear(), field.value.getMonth() + 1, field.value.getDate()) : null"
@update:model-value="(val: CalendarDate) => field.onChange(val.toDate(getLocalTimeZone()))"
layout="month-and-year" />
</PopoverContent>
</Popover>
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors"></FieldError>
</div>
</FieldContent>
</Field>
</VeeField>
</div>
<div>
<VeeField v-slot="{ field, errors }" name="reason">
<Field>
<FieldContent>
<FieldLabel>Reason</FieldLabel>
<Textarea :model-value="field.value" @update:model-value="field.onChange"
placeholder="Reason for LOA" class="resize-none h-28"></Textarea>
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors"></FieldError>
</div>
</FieldContent>
</Field>
</VeeField>
</div>
<div class="flex justify-end">
<Button type="submit">Submit</Button>
</div>
</form>
</div>
</div>
</template>