254 lines
13 KiB
Vue
254 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { batchPromotionSchema } from '@shared/schemas/promotionSchema';
|
|
import { useForm, Field as VeeField, FieldArray as VeeFieldArray } from 'vee-validate';
|
|
import { toTypedSchema } from '@vee-validate/zod';
|
|
import FieldSet from '../ui/field/FieldSet.vue';
|
|
import FieldLegend from '../ui/field/FieldLegend.vue';
|
|
import FieldDescription from '../ui/field/FieldDescription.vue';
|
|
import FieldGroup from '../ui/field/FieldGroup.vue';
|
|
import Combobox from '../ui/combobox/Combobox.vue';
|
|
import ComboboxAnchor from '../ui/combobox/ComboboxAnchor.vue';
|
|
import ComboboxInput from '../ui/combobox/ComboboxInput.vue';
|
|
import ComboboxList from '../ui/combobox/ComboboxList.vue';
|
|
import ComboboxEmpty from '../ui/combobox/ComboboxEmpty.vue';
|
|
import ComboboxGroup from '../ui/combobox/ComboboxGroup.vue';
|
|
import ComboboxItem from '../ui/combobox/ComboboxItem.vue';
|
|
import ComboboxItemIndicator from '../ui/combobox/ComboboxItemIndicator.vue';
|
|
import { computed, onMounted, ref } from 'vue';
|
|
import { MemberLight } from '@shared/types/member';
|
|
import { Check, Plus, X } from 'lucide-vue-next';
|
|
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, 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, resetForm } = useForm({
|
|
validationSchema: toTypedSchema(batchPromotionSchema),
|
|
validateOnMount: false,
|
|
})
|
|
|
|
const submitForm = handleSubmit(
|
|
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);
|
|
}
|
|
}
|
|
);
|
|
|
|
const submitError = ref<string>(null);
|
|
const formSubmitted = ref(false);
|
|
|
|
const allmembers = ref<MemberLight[]>([]);
|
|
const allRanks = ref<Rank[]>([]);
|
|
|
|
const memberById = computed(() => {
|
|
const map = new Map<number, MemberLight>();
|
|
for (const m of allmembers.value) {
|
|
map.set(m.id, m);
|
|
}
|
|
return map;
|
|
});
|
|
|
|
const rankById = computed(() => {
|
|
const map = new Map<number, Rank>();
|
|
for (const r of allRanks.value) {
|
|
map.set(r.id, r);
|
|
}
|
|
return map;
|
|
});
|
|
|
|
const memberSearch = ref('');
|
|
const rankSearch = ref('');
|
|
|
|
const filteredMembers = computed(() => {
|
|
const q = memberSearch?.value?.toLowerCase() ?? ""
|
|
const results: MemberLight[] = []
|
|
|
|
for (const m of allmembers.value ?? []) {
|
|
if (!q || (m.displayName || m.username).toLowerCase().includes(q)) {
|
|
results.push(m)
|
|
if (results.length >= 50) break
|
|
}
|
|
}
|
|
|
|
return results
|
|
})
|
|
|
|
const filteredRanks = computed(() =>
|
|
filterRanks(rankSearch.value)
|
|
);
|
|
|
|
function filterRanks(query: string): Rank[] {
|
|
if (!query) return allRanks.value;
|
|
|
|
const q = query.toLowerCase();
|
|
return allRanks.value.filter(r =>
|
|
r.name.toLowerCase().includes(q) ||
|
|
r.short_name.toLowerCase().includes(q)
|
|
);
|
|
}
|
|
|
|
onMounted(async () => {
|
|
allmembers.value = await getAllLightMembers()
|
|
allRanks.value = await getAllRanks();
|
|
})
|
|
|
|
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<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>
|
|
</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>
|
|
</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>
|
|
</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>
|
|
</FieldGroup>
|
|
</div>
|
|
<Button type="button" @click="push({})" class="w-full" variant="outline">
|
|
<Plus /> Member
|
|
</Button>
|
|
</div>
|
|
</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>
|
|
<Button @click="() => { formSubmitted = false; resetForm(); }" variant="secondary">
|
|
Submit Another
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|