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);
|
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) {
|
||||||
|
|||||||
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">
|
<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>
|
||||||
Reference in New Issue
Block a user