overhauled form system to new modern form
This commit is contained in:
@@ -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) {
|
||||
|
||||
51
shared/schemas/loaSchema.ts
Normal file
51
shared/schemas/loaSchema.ts
Normal 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.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user