From e0d9eeae92353b67ea6970004029f42579824ec5 Mon Sep 17 00:00:00 2001 From: ajdj100 Date: Tue, 16 Dec 2025 18:47:56 -0500 Subject: [PATCH] Rank change system UI first pass --- shared/schemas/promotionSchema.ts | 29 ++ shared/types/rank.ts | 7 + ui/src/api/rank.ts | 15 +- .../components/promotions/promotionForm.vue | 261 ++++++++++++++++++ ui/src/pages/RankChange.vue | 89 +++--- 5 files changed, 349 insertions(+), 52 deletions(-) create mode 100644 shared/schemas/promotionSchema.ts create mode 100644 shared/types/rank.ts create mode 100644 ui/src/components/promotions/promotionForm.vue diff --git a/shared/schemas/promotionSchema.ts b/shared/schemas/promotionSchema.ts new file mode 100644 index 0000000..6bfe234 --- /dev/null +++ b/shared/schemas/promotionSchema.ts @@ -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(); + 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", + }); + } + }); + }); diff --git a/shared/types/rank.ts b/shared/types/rank.ts new file mode 100644 index 0000000..2b68591 --- /dev/null +++ b/shared/types/rank.ts @@ -0,0 +1,7 @@ +export type Rank = { + id: number + name: string + short_name: string + category: string + sortOrder: number +} diff --git a/ui/src/api/rank.ts b/ui/src/api/rank.ts index 1fdcf09..278d100 100644 --- a/ui/src/api/rank.ts +++ b/ui/src/api/rank.ts @@ -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 { - const res = await fetch(`${addr}/ranks`) +export async function getAllRanks(): Promise { + 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 diff --git a/ui/src/components/promotions/promotionForm.vue b/ui/src/components/promotions/promotionForm.vue new file mode 100644 index 0000000..f338718 --- /dev/null +++ b/ui/src/components/promotions/promotionForm.vue @@ -0,0 +1,261 @@ + + + diff --git a/ui/src/pages/RankChange.vue b/ui/src/pages/RankChange.vue index 565d579..b320f07 100644 --- a/ui/src/pages/RankChange.vue +++ b/ui/src/pages/RankChange.vue @@ -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([]) -const ranks = ref([]) -const date = ref(today(getLocalTimeZone())) +// const members = ref([]) +// const ranks = ref([]) +// const date = ref(today(getLocalTimeZone())) -const currentMember = ref(null); -const currentRank = ref(null); -onMounted(async () => { - members.value = await getMembers(); - ranks.value = await getRanks(); -}); +// const currentMember = ref(null); +// const currentRank = ref(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."); +// }); +// } \ No newline at end of file