Began implementation for getting promotion history

This commit is contained in:
2025-12-16 22:28:32 -05:00
parent f9e5dacda8
commit 278690e094
8 changed files with 462 additions and 275 deletions

View File

@@ -21,29 +21,32 @@ import Button from '../ui/button/Button.vue';
import FieldError from '../ui/field/FieldError.vue';
import { getAllLightMembers } from '@/api/member';
import { Rank } from '@shared/types/rank';
import { getAllRanks } from '@/api/rank';
import { getAllRanks, submitRankChange } from '@/api/rank';
import { error } from 'console';
import Input from '../ui/input/Input.vue';
import Field from '../ui/field/Field.vue';
const { handleSubmit, errors, values } = useForm({
const { handleSubmit, errors, values, resetForm } = useForm({
validationSchema: toTypedSchema(batchPromotionSchema),
validateOnMount: false,
})
const submitForm = handleSubmit(
(vals) => {
console.log("VALID SUBMIT", vals);
},
(errors) => {
console.log("INVALID SUBMIT", errors);
async (vals) => {
try {
let output = vals;
output.promotions.map(p => p.start_date = new Date(p.start_date).toISOString())
await submitRankChange(output);
formSubmitted.value = true;
} catch (error) {
submitError.value = error;
console.error(error);
}
}
);
function onSubmit(vals) {
console.log('hi')
console.log(vals);
}
const submitError = ref<string>(null);
const formSubmitted = ref(false);
const allmembers = ref<MemberLight[]>([]);
const allRanks = ref<Rank[]>([]);
@@ -105,157 +108,146 @@ onMounted(async () => {
</script>
<template>
<form id="trainingForm" @submit.prevent="submitForm" class="w-full min-w-0 flex flex-col gap-6">
<VeeFieldArray name="promotions" v-slot="{ fields, push, remove }">
<FieldSet class="w-full min-w-0">
<div class="flex flex-col gap-2">
<FieldLegend class="text-lg tracking-tight">
Attendees
</FieldLegend>
<!--
<FieldDescription>
Add members who attended this session.
</FieldDescription> -->
<div class="h-4">
<p v-if="errors.promotions && typeof errors.promotions === 'string'"
class="text-sm text-red-500">
{{ errors.promotions }}
</p>
</div>
<!-- TABLE SHELL -->
<div class="relative w-full min-w-0">
<FieldGroup class="min-w-max">
<!-- HEADER -->
<div class="grid grid-cols-[200px_200px_150px_500px_1fr_auto]
gap-3 px-1 -mb-4
text-sm font-medium text-muted-foreground">
<div>Member</div>
<div>Rank</div>
<div>Date</div>
<div>Reason</div>
<div></div>
<div></div>
<div class="w-xl">
<form v-if="!formSubmitted" id="trainingForm" @submit.prevent="submitForm"
class="w-full min-w-0 flex flex-col gap-6">
<VeeFieldArray name="promotions" v-slot="{ fields, push, remove }">
<FieldSet class="w-full min-w-0">
<div class="flex flex-col gap-2">
<div>
<FieldLegend class="scroll-m-20 text-2xl font-semibold tracking-tight">
Promotion Form
</FieldLegend>
<div class="h-6">
<p v-if="errors.promotions && typeof errors.promotions === 'string'"
class="text-sm text-red-500">
{{ errors.promotions }}
</p>
</div>
<!-- BODY -->
<div class="flex flex-col gap-2">
<div v-for="(row, index) in fields" :key="row.key" class="grid grid-cols-[200px_200px_150px_1fr_auto]
gap-3 items-start">
<!-- Member -->
<VeeField :name="`promotions[${index}].member_id`" v-slot="{ field, errors }">
<div class="flex flex-col min-w-0">
<Combobox :model-value="field.value" @update:model-value="field.onChange"
:ignore-filter="true">
<ComboboxAnchor>
<ComboboxInput class="w-full pl-3" placeholder="Search members…"
:display-value="id =>
memberById.get(id)?.displayName ||
memberById.get(id)?.username
" @input="memberSearch = $event.target.value" />
</ComboboxAnchor>
<ComboboxList>
<ComboboxEmpty>No results</ComboboxEmpty>
<ComboboxGroup>
<div
class="max-h-80 overflow-y-auto min-w-[12rem] scrollbar-themed">
<ComboboxItem v-for="member in filteredMembers"
:key="member.id" :value="member.id">
{{ member.displayName || member.username }}
<ComboboxItemIndicator>
<Check />
</ComboboxItemIndicator>
</ComboboxItem>
</div>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<div class="h-5">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</div>
</VeeField>
<!-- Rank -->
<VeeField :name="`promotions[${index}].rank_id`" v-slot="{ field, errors }">
<div class="flex flex-col min-w-0">
<Combobox :model-value="field.value" @update:model-value="field.onChange"
:ignore-filter="true">
<ComboboxAnchor>
<ComboboxInput class="w-full pl-3" placeholder="Select rank"
:display-value="id => rankById.get(id)?.name"
@input="rankSearch = $event.target.value" />
</ComboboxAnchor>
<ComboboxList>
<ComboboxEmpty>No results</ComboboxEmpty>
<ComboboxGroup>
<ComboboxItem v-for="rank in filteredRanks" :key="rank.id"
:value="rank.id">
{{ rank.name }}
<ComboboxItemIndicator>
<Check />
</ComboboxItemIndicator>
</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<div class="h-5">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</div>
</VeeField>
<!-- Date -->
<VeeField :name="`promotions[${index}].start_date`" v-slot="{ field, errors }">
<Field>
<div>
<Input type="date" v-bind="field" />
</div>
<!-- TABLE SHELL -->
<div class="">
<FieldGroup class="">
<!-- HEADER -->
<div class="grid grid-cols-[200px_200px_150px_1fr_auto]
gap-3 px-1 -mb-4
text-sm font-medium text-muted-foreground">
<div>Member</div>
<div>Rank</div>
<div>Date</div>
<div></div>
<div></div>
</div>
<!-- BODY -->
<div class="flex flex-col gap-2">
<div v-for="(row, index) in fields" :key="row.key" class="grid grid-cols-[200px_200px_150px_1fr_auto]
gap-3 items-start">
<!-- Member -->
<VeeField :name="`promotions[${index}].member_id`" v-slot="{ field, errors }">
<div class="flex flex-col min-w-0">
<Combobox :model-value="field.value" @update:model-value="field.onChange"
:ignore-filter="true">
<ComboboxAnchor>
<ComboboxInput class="w-full pl-3" placeholder="Search members…"
:display-value="id =>
memberById.get(id)?.displayName ||
memberById.get(id)?.username
" @input="memberSearch = $event.target.value" />
</ComboboxAnchor>
<ComboboxList>
<ComboboxEmpty>No results</ComboboxEmpty>
<ComboboxGroup>
<div
class="max-h-80 overflow-y-auto min-w-[12rem] scrollbar-themed">
<ComboboxItem v-for="member in filteredMembers"
:key="member.id" :value="member.id">
{{ member.displayName || member.username }}
<ComboboxItemIndicator>
<Check />
</ComboboxItemIndicator>
</ComboboxItem>
</div>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<div class="h-5">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</div>
</Field>
</VeeField>
<!-- Reason -->
<VeeField :name="`promotions[${index}].reason`" v-slot="{ field, errors }">
<Field>
<div>
<Input v-bind="field" />
</VeeField>
<!-- Rank -->
<VeeField :name="`promotions[${index}].rank_id`" v-slot="{ field, errors }">
<div class="flex flex-col min-w-0">
<Combobox :model-value="field.value" @update:model-value="field.onChange"
:ignore-filter="true">
<ComboboxAnchor>
<ComboboxInput class="w-full pl-3" placeholder="Select rank"
:display-value="id => rankById.get(id)?.name"
@input="rankSearch = $event.target.value" />
</ComboboxAnchor>
<ComboboxList>
<ComboboxEmpty>No results</ComboboxEmpty>
<ComboboxGroup>
<div
class="max-h-80 overflow-y-auto min-w-[12rem] scrollbar-themed">
<ComboboxItem v-for="rank in filteredRanks" :key="rank.id"
:value="rank.id">
{{ rank.name }}
<ComboboxItemIndicator>
<Check />
</ComboboxItemIndicator>
</ComboboxItem>
</div>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<div class="h-5">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</div>
</Field>
</VeeField>
<!-- Remove -->
<div class="flex justify-center">
<Button type="button" variant="ghost" size="icon" @click="remove(index)">
<X />
</Button>
</VeeField>
<!-- Date -->
<VeeField :name="`promotions[${index}].start_date`" v-slot="{ field, errors }">
<Field>
<div>
<Input type="date" v-bind="field" />
<div class="h-5">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</div>
</Field>
</VeeField>
<!-- Remove -->
<div class="flex justify-end">
<Button type="button" variant="ghost" size="icon" @click="remove(index)">
<X />
</Button>
</div>
</div>
</div>
</div>
</FieldGroup>
</FieldGroup>
</div>
<Button type="button" @click="push({})" class="w-full" variant="outline">
<Plus /> Member
</Button>
</div>
<Button type="button" @click="push({})" class="" variant="outline">
<Plus /> Member
</Button>
</FieldSet>
</VeeFieldArray>
<div class="flex justify-end items-center gap-5">
<p v-if="submitError" class="text-destructive">{{ submitError }}</p>
<Button type="submit">Submit</Button>
</div>
</form>
<div v-else>
<div class="flex flex-col max-w-sm justify-center gap-4 py-24 mx-auto">
<div class="text-left">
<h2 class="text-2xl font-semibold mb-2">Successfully Submitted</h2>
<p class="text-muted-foreground">Your promotions have been recorded.</p>
</div>
</FieldSet>
</VeeFieldArray>
<div class="flex justify-end">
<Button type="submit">Submit</Button>
<Button @click="() => { formSubmitted = false; resetForm(); }" variant="secondary">
Submit Another
</Button>
</div>
</div>
</form>
</div>
</template>

View File

@@ -0,0 +1,175 @@
<script setup lang="ts">
import { pagination } from '@shared/types/pagination';
import { ref } from 'vue';
const expanded = ref<number | null>(null);
const hoverID = ref<number | null>(null);
const pageNum = ref<number>(1);
const pageData = ref<pagination>();
const pageSize = ref<number>(15)
const pageSizeOptions = [10, 15, 30]
function setPageSize(size: number) {
pageSize.value = size
pageNum.value = 1;
// loadLOAs();
}
function setPage(pagenum: number) {
pageNum.value = pagenum;
// loadLOAs();
}
</script>
<template>
<div>
<Dialog :open="isExtending" @update:open="(val) => isExtending = val">
<DialogContent>
<DialogHeader>
<DialogTitle>Extend {{ targetLOA.name }}'s Leave of Absence </DialogTitle>
</DialogHeader>
<div class="flex gap-5">
<Calendar v-model="extendTo" class="rounded-md border shadow-sm w-min" layout="month-and-year"
:min-value="toCalendarDate(targetEnd)"
:max-value="toCalendarDate(targetEnd).add({ years: 1 })" />
<div class="flex flex-col w-full gap-3 px-2">
<p>Quick Options</p>
<Button variant="outline" @click="extendTo = toCalendarDate(targetEnd).add({ days: 7 })">1
Week</Button>
<Button variant="outline" @click="extendTo = toCalendarDate(targetEnd).add({ months: 1 })">1
Month</Button>
</div>
</div>
<div class="flex justify-end gap-4">
<Button variant="outline" @click="isExtending = false">Cancel</Button>
<Button @click="commitExtend">Extend</Button>
</div>
</DialogContent>
</Dialog>
<div class="max-w-7xl w-full mx-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Member</TableHead>
<TableHead>Type</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<!-- <TableHead class="w-[500px]">Reason</TableHead> -->
<TableHead>Posted on</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-for="post in LOAList" :key="post.id">
<TableRow class="hover:bg-muted/50 cursor-pointer" @click="expanded = post.id"
@mouseenter="hoverID = post.id" @mouseleave="hoverID = null" :class="{
'border-b-0': expanded === post.id,
'bg-muted/50': hoverID === post.id
}">
<TableCell class="font-medium">
<MemberCard :member-id="post.member_id"></MemberCard>
</TableCell>
<TableCell>{{ post.type_name }}</TableCell>
<TableCell>{{ formatDate(post.start_date) }}</TableCell>
<TableCell>{{ post.extended_till ? formatDate(post.extended_till) :
formatDate(post.end_date) }}
</TableCell>
<!-- <TableCell>{{ post.reason }}</TableCell> -->
<TableCell>{{ formatDate(post.filed_date) }}</TableCell>
<TableCell>
<Badge v-if="loaStatus(post) === 'Upcoming'" class="bg-blue-400">Upcoming</Badge>
<Badge v-else-if="loaStatus(post) === 'Active'" class="bg-green-500">Active</Badge>
<Badge v-else-if="loaStatus(post) === 'Overdue'" class="bg-yellow-400">Overdue</Badge>
<Badge v-else class="bg-gray-400">Ended</Badge>
</TableCell>
<TableCell @click.stop="" class="text-right">
<DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer">
<Button variant="ghost">
<Ellipsis class="size-6"></Ellipsis>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem v-if="!post.closed && props.adminMode"
@click="isExtending = true; targetLOA = post">
Extend
</DropdownMenuItem>
<DropdownMenuItem v-if="!post.closed" :variant="'destructive'"
@click="cancelAndReload(post.id)">{{ loaStatus(post) === 'Upcoming' ?
'Cancel' :
'End' }}
</DropdownMenuItem>
<!-- Fallback: no actions available -->
<p v-if="post.closed || (!props.adminMode && post.closed)"
class="p-2 text-center text-sm">
No actions
</p>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
<TableCell>
<Button v-if="expanded === post.id" @click.stop="expanded = null" variant="ghost">
<ChevronUp class="size-6" />
</Button>
<Button v-else @click.stop="expanded = post.id" variant="ghost">
<ChevronDown class="size-6" />
</Button>
</TableCell>
</TableRow>
<TableRow v-if="expanded === post.id" @mouseenter="hoverID = post.id"
@mouseleave="hoverID = null" :class="{ 'bg-muted/50 border-t-0': hoverID === post.id }">
<TableCell :colspan="8" class="p-0">
<div class="w-full p-3 mb-6 space-y-3">
<div class="flex justify-between items-start gap-4">
<div class="flex-1">
<!-- Title -->
<p class="text-md font-semibold text-foreground">
Reason
</p>
<!-- Content -->
<p
class="mt-1 text-md whitespace-pre-wrap leading-relaxed text-muted-foreground">
{{ post.reason }}
</p>
</div>
</div>
</div>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
<div class="mt-5 flex justify-between">
<div></div>
<Pagination v-slot="{ page }" :items-per-page="pageData?.pageSize || 10" :total="pageData?.total || 10"
:default-page="2" :page="pageNum" @update:page="setPage">
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template v-for="(item, index) in items" :key="index">
<PaginationItem v-if="item.type === 'page'" :value="item.value"
:is-active="item.value === page">
{{ item.value }}
</PaginationItem>
</template>
<PaginationEllipsis :index="4" />
<PaginationNext />
</PaginationContent>
</Pagination>
<div class="flex items-center gap-3 text-sm">
<p class="text-muted-foreground text-nowrap">Per page:</p>
<button v-for="size in pageSizeOptions" :key="size" @click="setPageSize(size)"
class="px-2 py-1 rounded transition-colors" :class="{
'bg-muted font-semibold': pageSize === size,
'hover:bg-muted/50': pageSize !== size
}">
{{ size }}
</button>
</div>
</div>
</div>
</div>
</template>