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); console.log(LOARequest);
try { 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); await createNewLOA(LOARequest);
res.sendStatus(201); res.sendStatus(201);
} catch (error) { } 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"> <script setup lang="ts">
import { Check, Search } from "lucide-vue-next" import { Check, Search } from "lucide-vue-next"
import { Combobox, ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox" import { ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox"
import { onMounted, ref } from "vue"; import { onMounted, ref, watch } from "vue";
import { Member, getMembers } from "@/api/member"; import { Member, getMembers } from "@/api/member";
import Button from "@/components/ui/button/Button.vue"; import Button from "@/components/ui/button/Button.vue";
import { import {
CalendarDate, CalendarDate,
DateFormatter, DateFormatter,
fromDate,
getLocalTimeZone, getLocalTimeZone,
parseDate,
} from "@internationalized/date" } from "@internationalized/date"
import type { DateRange } from "reka-ui" import type { DateRange } from "reka-ui"
import type { Ref } from "vue" import type { Ref } from "vue"
@@ -18,10 +20,27 @@ import { RangeCalendar } from "@/components/ui/range-calendar"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CalendarIcon } from "lucide-vue-next" import { CalendarIcon } from "lucide-vue-next"
import Textarea from "@/components/ui/textarea/Textarea.vue"; import Textarea from "@/components/ui/textarea/Textarea.vue";
import { submitLOA } from "@/api/loa"; // <-- import the submit function import { getLoaTypes, submitLOA } from "@/api/loa"; // <-- import the submit function
import { LOARequest } from "@shared/types/loa"; 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 members = ref<Member[]>([])
const loaTypes = ref<LOAType[]>();
const currentMember = ref<Member | null>(null); const currentMember = ref<Member | null>(null);
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@@ -32,73 +51,40 @@ const props = withDefaults(defineProps<{
member: null, 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({ watch(values, (v) => {
// start: new CalendarDate(2022, 1, 20), console.log("Form values:", v)
// end: new CalendarDate(2022, 1, 20).add({ days: 20 }), })
}) as Ref<DateRange>
const reason = ref(""); // <-- reason for LOA const onSubmit = handleSubmit((values) => {
const submitting = ref(false); console.log(values);
const submitError = ref<string | null>(null); })
const submitSuccess = ref(false);
onMounted(async () => { onMounted(async () => {
if (props.member) { if (props.member) {
currentMember.value = props.member; currentMember.value = props.member;
} }
if (props.adminMode) {
members.value = await getMembers();
}
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> </script>
<template> <template>
@@ -114,60 +100,138 @@ function toMariaDBDatetime(date: Date): string {
</div> </div>
</div> </div>
<div class="flex-1 flex flex-col gap-5"> <div class="flex-1 flex flex-col gap-5">
<div class="flex w-full gap-5 "> <form @submit="onSubmit" class="flex flex-col gap-2">
<Combobox class="w-1/2" v-model="currentMember" :disabled="!adminMode"> <div class="flex w-full gap-5">
<ComboboxAnchor class="w-full"> <VeeField v-slot="{ field, errors }" name="member_id">
<ComboboxInput placeholder="Search members..." class="w-full pl-9" <Field>
:display-value="(v) => v ? v.member_name : ''" /> <FieldContent>
</ComboboxAnchor> <FieldLabel>Member</FieldLabel>
<ComboboxList class="w-full"> <Combobox :model-value="field.value" @update:model-value="field.onChange"
<ComboboxEmpty class="text-muted-foreground">No results</ComboboxEmpty> :disabled="!adminMode">
<ComboboxGroup> <ComboboxAnchor class="w-full">
<template v-for="member in members" :key="member.member_id"> <ComboboxInput placeholder="Search members..." class="w-full pl-3"
<ComboboxItem :value="member" :display-value="(id) => {
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5"> const m = members.find(mem => mem.member_id === id)
{{ member.member_name }} return m ? m.member_name : ''
<ComboboxItemIndicator class="absolute left-2 inline-flex items-center"> }" />
<Check class="h-4 w-4" /> </ComboboxAnchor>
</ComboboxItemIndicator> <ComboboxList class="*:w-64">
</ComboboxItem> <ComboboxEmpty class="text-muted-foreground w-full">No results</ComboboxEmpty>
</template> <ComboboxGroup>
</ComboboxGroup> <template v-for="member in members" :key="member.member_id">
</ComboboxList> <ComboboxItem :value="member.member_id"
</Combobox> class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5 w-full">
<Popover> {{ member.member_name }}
<PopoverTrigger as-child> <ComboboxItemIndicator
<Button variant="outline" :class="cn( class="absolute left-2 inline-flex items-center">
'w-1/2 justify-start text-left font-normal', <Check class="h-4 w-4" />
!value && 'text-muted-foreground', </ComboboxItemIndicator>
)"> </ComboboxItem>
<CalendarIcon class="mr-2 h-4 w-4" /> </template>
<template v-if="value.start"> </ComboboxGroup>
<template v-if="value.end"> </ComboboxList>
{{ df.format(value.start.toDate(getLocalTimeZone())) }} - {{ </Combobox>
df.format(value.end.toDate(getLocalTimeZone())) }} <div class="h-4">
</template> <FieldError v-if="errors.length" :errors="errors"></FieldError>
<template v-else> </div>
{{ df.format(value.start.toDate(getLocalTimeZone())) }} </FieldContent>
</template> </Field>
</template> </VeeField>
<template v-else> <VeeField v-slot="{ field, errors }" name="type">
Pick a date <Field class="w-full">
</template> <FieldContent>
</Button> <FieldLabel>Type</FieldLabel>
</PopoverTrigger> <Select :model-value="field.value" @update:model-value="field.onChange">
<PopoverContent class="w-auto p-0"> <SelectTrigger class="w-full">
<RangeCalendar v-model="value" initial-focus :number-of-months="2" <SelectValue></SelectValue>
@update:start-value="(startDate) => value.start = startDate" /> </SelectTrigger>
</PopoverContent> <SelectContent>
</Popover> <SelectItem v-for="type in loaTypes" :value="type">
</div> {{ type.name }}
<Textarea v-model="reason" placeholder="Reason for LOA" class="w-full resize-none" /> </SelectItem>
<div class="flex justify-end"> </SelectContent>
<Button :onClick="handleSubmit" :disabled="submitting" class="w-min">Submit</Button> </Select>
</div> <div class="h-4">
<div v-if="submitError" class="text-red-500 text-sm mt-2">{{ submitError }}</div> <FieldError v-if="errors.length" :errors="errors"></FieldError>
<div v-if="submitSuccess" class="text-green-500 text-sm mt-2">LOA submitted successfully!</div> </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>
</div> </div>
</template> </template>