Rank change system UI first pass

This commit is contained in:
2025-12-16 18:47:56 -05:00
parent 7990c86a86
commit e0d9eeae92
5 changed files with 349 additions and 52 deletions

View File

@@ -0,0 +1,29 @@
import { z } from "zod";
export const batchPromotionMemberSchema = z.object({
member_id: z.number({ invalid_type_error: "Must select a member" }).int().positive(),
rank_id: z.number({ invalid_type_error: "Must select a rank" }).int().positive(),
start_date: z.string().refine((val) => !isNaN(Date.parse(val)), {
message: "Must be a valid date",
}),
reason: z.string({ required_error: "Reason is required" }).max(50, "Reason too long"),
});
export const batchPromotionSchema = z.object({
promotions: z.array(batchPromotionMemberSchema).nonempty({ message: "At least one promotion is required" }),
})
.superRefine((data, ctx) => {
// optional: check for duplicate member_ids
const memberCounts = new Map<number, number>();
data.promotions.forEach((p, index) => {
memberCounts.set(p.member_id, (memberCounts.get(p.member_id) ?? 0) + 1);
if (memberCounts.get(p.member_id)! > 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["promotions", index, "member_id"],
message: "Duplicate member in batch is not allowed",
});
}
});
});

7
shared/types/rank.ts Normal file
View File

@@ -0,0 +1,7 @@
export type Rank = {
id: number
name: string
short_name: string
category: string
sortOrder: number
}

View File

@@ -1,22 +1,17 @@
export type Rank = {
id: number
name: string
short_name: string
sortOrder: number
}
import { Rank } from '@shared/types/rank'
// @ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getRanks(): Promise<Rank[]> {
const res = await fetch(`${addr}/ranks`)
export async function getAllRanks(): Promise<Rank[]> {
const res = await fetch(`${addr}/ranks`, {
credentials: 'include'
})
if (res.ok) {
return res.json()
} else {
console.error("Something went wrong approving the application")
}
}
// Placeholder: submit a rank change

View File

@@ -0,0 +1,261 @@
<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 } 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({
validationSchema: toTypedSchema(batchPromotionSchema),
validateOnMount: false,
})
const submitForm = handleSubmit(
(vals) => {
console.log("VALID SUBMIT", vals);
},
(errors) => {
console.log("INVALID SUBMIT", errors);
}
);
function onSubmit(vals) {
console.log('hi')
console.log(vals);
}
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>
<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>
<!-- 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 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" />
<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>
</div>
</div>
</div>
</FieldGroup>
</div>
<Button type="button" @click="push({})" class="" variant="outline">
<Plus /> Member
</Button>
</div>
</FieldSet>
</VeeFieldArray>
<div class="flex justify-end">
<Button type="submit">Submit</Button>
</div>
</form>
</template>

View File

@@ -2,58 +2,63 @@
import { Check, Search } from "lucide-vue-next"
import { Combobox, ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { getRanks, Rank, submitRankChange } from "@/api/rank"
import { onMounted, ref } from "vue";
import { Member, getMembers } from "@/api/member";
import { getAllLightMembers } from "@/api/member";
import { MemberLight } from "@shared/types/member";
import { cn } from "@/lib/utils"
import { CalendarIcon } from "lucide-vue-next"
import { batchPromotionSchema } from '@shared/schemas/promotionSchema'
import PromotionForm from "@/components/promotions/promotionForm.vue";
import {
DateFormatter,
// import {
// DateFormatter,
DateValue,
// DateValue,
getLocalTimeZone,
today,
} from "@internationalized/date"
// getLocalTimeZone,
// today,
// } from "@internationalized/date"
import Button from "@/components/ui/button/Button.vue";
import Calendar from "@/components/ui/calendar/Calendar.vue";
// import Button from "@/components/ui/button/Button.vue";
// import Calendar from "@/components/ui/calendar/Calendar.vue";
const members = ref<Member[]>([])
const ranks = ref<Rank[]>([])
const date = ref<DateValue>(today(getLocalTimeZone()))
// const members = ref<MemberLight[]>([])
// const ranks = ref<Rank[]>([])
// const date = ref<DateValue>(today(getLocalTimeZone()))
const currentMember = ref<Member | null>(null);
const currentRank = ref<Rank | null>(null);
onMounted(async () => {
members.value = await getMembers();
ranks.value = await getRanks();
});
// const currentMember = ref<MemberLight | null>(null);
// const currentRank = ref<Rank | null>(null);
// onMounted(async () => {
// members.value = await getAllLightMembers();
// ranks.value = await getRanks();
// });
const df = new DateFormatter("en-US", {
dateStyle: "long",
})
// const df = new DateFormatter("en-US", {
// dateStyle: "long",
// })
function submit() {
submitRankChange(currentMember.value.member_id, currentRank.value?.id, date.value.toString())
.then(() => {
alert("Rank change submitted!");
currentMember.value = null;
currentRank.value = null;
date.value = today(getLocalTimeZone());
})
.catch((err) => {
console.error(err);
alert("Failed to submit rank change.");
});
}
// function submit() {
// submitRankChange(currentMember.value.member_id, currentRank.value?.id, date.value.toString())
// .then(() => {
// alert("Rank change submitted!");
// currentMember.value = null;
// currentRank.value = null;
// date.value = today(getLocalTimeZone());
// })
// .catch((err) => {
// console.error(err);
// alert("Failed to submit rank change.");
// });
// }
</script>
<template>
<div class="flex max-w-5xl justify-center gap-5 mx-auto mt-10">
<div class="mx-auto w-full max-w-7xl px-4">
<PromotionForm />
</div>
<!-- <div class="flex max-w-5xl justify-center gap-5 mx-auto mt-10">
<Combobox v-model="currentMember">
<ComboboxAnchor class="w-[300px]">
<ComboboxInput placeholder="Search members..." class="w-full pl-9"
@@ -71,12 +76,12 @@ function submit() {
</ComboboxItemIndicator>
</ComboboxItem>
</template>
</ComboboxGroup>
</ComboboxList>
</Combobox>
</ComboboxGroup>
</ComboboxList>
</Combobox> -->
<!-- Rank Combobox -->
<Combobox v-model="currentRank">
<!-- Rank Combobox -->
<!-- <Combobox v-model="currentRank">
<ComboboxAnchor class="w-[300px]">
<ComboboxInput placeholder="Search ranks..." class="w-full pl-9"
:display-value="(v) => v ? v.short_name : ''" />
@@ -111,5 +116,5 @@ function submit() {
</PopoverContent>
</Popover>
<Button @click="submit">Submit</Button>
</div>
</div> -->
</template>