From 7990c86a86cf5b3efe3f2e2d35334111ed959dee Mon Sep 17 00:00:00 2001 From: ajdj100 Date: Tue, 16 Dec 2025 14:22:24 -0500 Subject: [PATCH 01/66] disabled transfer request navigation --- ui/src/components/Navigation/Navbar.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/components/Navigation/Navbar.vue b/ui/src/components/Navigation/Navbar.vue index b300ce2..7b1c36c 100644 --- a/ui/src/components/Navigation/Navbar.vue +++ b/ui/src/components/Navigation/Navbar.vue @@ -85,11 +85,11 @@ function blurAfter() { - + @@ -123,13 +123,13 @@ function blurAfter() { - Transfer Requests - + --> From e0d9eeae92353b67ea6970004029f42579824ec5 Mon Sep 17 00:00:00 2001 From: ajdj100 Date: Tue, 16 Dec 2025 18:47:56 -0500 Subject: [PATCH 02/66] 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 From f9e5dacda8fb443f987978c6c4cfe9b0598e58a0 Mon Sep 17 00:00:00 2001 From: ajdj100 Date: Tue, 16 Dec 2025 19:31:32 -0500 Subject: [PATCH 03/66] tweaked error message for empty promotion batch --- shared/schemas/promotionSchema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/schemas/promotionSchema.ts b/shared/schemas/promotionSchema.ts index 6bfe234..80ebaac 100644 --- a/shared/schemas/promotionSchema.ts +++ b/shared/schemas/promotionSchema.ts @@ -10,7 +10,7 @@ export const batchPromotionMemberSchema = z.object({ }); export const batchPromotionSchema = z.object({ - promotions: z.array(batchPromotionMemberSchema).nonempty({ message: "At least one promotion is required" }), + promotions: z.array(batchPromotionMemberSchema, { message: "At least one promotion is required" }).nonempty({ message: "At least one promotion is required" }), }) .superRefine((data, ctx) => { From 278690e094e61f093fefa5170f523ecfc9e6cdf1 Mon Sep 17 00:00:00 2001 From: ajdj100 Date: Tue, 16 Dec 2025 22:28:32 -0500 Subject: [PATCH 04/66] Began implementation for getting promotion history --- api/src/routes/ranks.ts | 38 ++- api/src/services/rankService.ts | 79 +++++ shared/schemas/promotionSchema.ts | 5 +- shared/types/rank.ts | 4 + ui/src/api/rank.ts | 12 +- .../components/promotions/promotionForm.vue | 292 +++++++++--------- .../components/promotions/promotionList.vue | 175 +++++++++++ ui/src/pages/RankChange.vue | 132 ++------ 8 files changed, 462 insertions(+), 275 deletions(-) create mode 100644 ui/src/components/promotions/promotionList.vue diff --git a/api/src/routes/ranks.ts b/api/src/routes/ranks.ts index 5eff733..af22a03 100644 --- a/api/src/routes/ranks.ts +++ b/api/src/routes/ranks.ts @@ -1,6 +1,7 @@ import { MemberState } from "@app/shared/types/member"; import { requireLogin, requireMemberState, requireRole } from "../middleware/auth"; -import { getAllRanks, insertMemberRank } from "../services/rankService"; +import { batchInsertMemberRank, getAllRanks, getPromotionHistorySummary, getPromotionsOnDay, insertMemberRank } from "../services/rankService"; +import { BatchPromotion, BatchPromotionMember } from '@app/shared/schemas/promotionSchema' import express = require('express'); const r = express.Router(); @@ -11,11 +12,13 @@ r.use(requireLogin) ur.use(requireLogin) //insert a new latest rank for a user -ur.post('/', [requireRole(["17th Command", "17th Administrator", "17th HQ"]), requireMemberState(MemberState.Member)], async (req, res) => { - 3 +ur.post('/', [requireRole(["17th Command", "17th Administrator", "17th HQ"]), requireMemberState(MemberState.Member)], async (req: express.Request, res: express.Response) => { try { - const change = req.body?.change; - await insertMemberRank(change.member_id, change.rank_id, change.date); + const change = req.body.promotions as BatchPromotionMember[]; + const author = req.user.id; + if (!change) res.sendStatus(400); + + await batchInsertMemberRank(change, author); res.sendStatus(201); } catch (err) { @@ -24,6 +27,31 @@ ur.post('/', [requireRole(["17th Command", "17th Administrator", "17th HQ"]), re } }); +ur.get('/', async (req: express.Request, res: express.Response) => { + try { + const promos = await getPromotionHistorySummary(); + console.log(promos); + res.status(200).json(promos); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +ur.get('/:day', async (req: express.Request, res: express.Response) => { + try { + if (!req.params.day) res.sendStatus(400); + + let day = new Date(req.params.day) + const promos = await getPromotionsOnDay(day); + console.log(promos); + res.status(200).json(promos); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } +}) + //get all ranks r.get('/', async (req, res) => { try { diff --git a/api/src/services/rankService.ts b/api/src/services/rankService.ts index ada4ea0..08b6804 100644 --- a/api/src/services/rankService.ts +++ b/api/src/services/rankService.ts @@ -1,4 +1,8 @@ +import { BatchPromotion, BatchPromotionMember } from "@app/shared/schemas/promotionSchema"; +import { PromotionSummary } from "@app/shared/types/rank" import pool from "../db"; +import { PagedData } from "@app/shared/types/pagination"; +import { toDateTime } from "@app/shared/utils/time"; export async function getAllRanks() { const rows = await pool.query( @@ -30,3 +34,78 @@ export async function insertMemberRank(member_id: number, rank_id: number, date? await pool.query(sql, params); } + + +export async function batchInsertMemberRank(promos: BatchPromotionMember[], author: number) { + try { + var con = await pool.getConnection(); + console.log(promos); + promos.forEach(p => { + con.query(`CALL sp_update_member_rank(?, ?, ?, ?, ?, ?)`, [p.member_id, p.rank_id, author, author, "Rank Change", toDateTime(new Date(p.start_date))]) + }); + + con.commit(); + return + } catch (error) { + throw error; //pass it up + } finally { + con.release(); + } +} + +export async function getPromotionHistorySummary(page: number = 1, pageSize: number = 15): Promise> { + + const offset = (page - 1) * pageSize; + + let sql = `SELECT + DATE(start_date) AS entry_day + FROM + members_ranks + GROUP BY + entry_day + ORDER BY + entry_day DESC + LIMIT ? OFFSET ?;` + + let promoList: PromotionSummary[] = await pool.query(sql, [pageSize, offset]) as PromotionSummary[]; + + let loaCount = Number((await pool.query(`SELECT + COUNT(*) AS total_grouped_days_count + FROM + ( + SELECT DISTINCT DATE(start_date) + FROM members_ranks + ) AS grouped_days;`))[0]); + + console.log(loaCount); + let pageCount = loaCount / pageSize; + + let output: PagedData = { data: promoList, pagination: { page: page, pageSize: pageSize, total: loaCount, totalPages: pageCount } } + return output; +} + +export async function getPromotionsOnDay(day: Date): Promise { + + // Convert the Date object to a 'YYYY-MM-DD' string for the SQL filter + // This assumes pool.query is used with a database that accepts this format for comparison. + const dayString = day.toISOString().split('T')[0]; + + // SQL query to fetch all records from members_unit for the specified day + let sql = ` + SELECT + member_id, + unit_id AS rank_id, -- Using unit_id as a proxy for rank_id based on the data structure + start_date + FROM + members_unit + WHERE + DATE(start_date) = ? + ORDER BY + start_date ASC; + `; + + + let batchPromotion = await pool.query(sql, [dayString]) as BatchPromotion[]; + + return batchPromotion; +} \ No newline at end of file diff --git a/shared/schemas/promotionSchema.ts b/shared/schemas/promotionSchema.ts index 80ebaac..6c78b75 100644 --- a/shared/schemas/promotionSchema.ts +++ b/shared/schemas/promotionSchema.ts @@ -6,7 +6,6 @@ export const batchPromotionMemberSchema = z.object({ 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({ @@ -27,3 +26,7 @@ export const batchPromotionSchema = z.object({ } }); }); + + +export type BatchPromotion = z.infer; +export type BatchPromotionMember = z.infer; \ No newline at end of file diff --git a/shared/types/rank.ts b/shared/types/rank.ts index 2b68591..f08ef68 100644 --- a/shared/types/rank.ts +++ b/shared/types/rank.ts @@ -5,3 +5,7 @@ export type Rank = { category: string sortOrder: number } + +export interface PromotionSummary { + entry_day: Date; +} \ No newline at end of file diff --git a/ui/src/api/rank.ts b/ui/src/api/rank.ts index 278d100..52f8631 100644 --- a/ui/src/api/rank.ts +++ b/ui/src/api/rank.ts @@ -1,4 +1,6 @@ +import { BatchPromotion } from '@shared/schemas/promotionSchema'; import { Rank } from '@shared/types/rank' + // @ts-ignore const addr = import.meta.env.VITE_APIHOST; @@ -15,19 +17,19 @@ export async function getAllRanks(): Promise { } // Placeholder: submit a rank change -export async function submitRankChange(member_id: number, rank_id: number, date: string): Promise<{ ok: boolean }> { +export async function submitRankChange(promo: BatchPromotion) { const res = await fetch(`${addr}/memberRanks`, { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ change: { member_id, rank_id, date } }), + credentials: 'include', + body: JSON.stringify(promo), }) if (res.ok) { - return { ok: true } + return } else { - console.error("Failed to submit rank change") - return { ok: false } + throw new Error("Failed to submit rank change: Server error"); } } \ No newline at end of file diff --git a/ui/src/components/promotions/promotionForm.vue b/ui/src/components/promotions/promotionForm.vue index f338718..786c6b0 100644 --- a/ui/src/components/promotions/promotionForm.vue +++ b/ui/src/components/promotions/promotionForm.vue @@ -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(null); +const formSubmitted = ref(false); const allmembers = ref([]); const allRanks = ref([]); @@ -105,157 +108,146 @@ onMounted(async () => { diff --git a/ui/src/components/promotions/promotionList.vue b/ui/src/components/promotions/promotionList.vue new file mode 100644 index 0000000..a8ccb77 --- /dev/null +++ b/ui/src/components/promotions/promotionList.vue @@ -0,0 +1,175 @@ + + diff --git a/ui/src/pages/RankChange.vue b/ui/src/pages/RankChange.vue index b320f07..f5fb314 100644 --- a/ui/src/pages/RankChange.vue +++ b/ui/src/pages/RankChange.vue @@ -1,120 +1,24 @@ \ No newline at end of file + +
+ +
+ + +
+
+ +
+
+ + + + From 43763853f890fe9245d3c12ff93d56a560172ba0 Mon Sep 17 00:00:00 2001 From: ajdj100 Date: Wed, 17 Dec 2025 16:02:20 -0500 Subject: [PATCH 05/66] Finished list render system --- ui/src/api/rank.ts | 72 +++++--- .../components/promotions/promotionList.vue | 167 ++++++++---------- .../promotions/promotionListDay.vue | 17 ++ ui/src/pages/RankChange.vue | 2 +- 4 files changed, 137 insertions(+), 121 deletions(-) create mode 100644 ui/src/components/promotions/promotionListDay.vue diff --git a/ui/src/api/rank.ts b/ui/src/api/rank.ts index 52f8631..d1e4eae 100644 --- a/ui/src/api/rank.ts +++ b/ui/src/api/rank.ts @@ -1,35 +1,61 @@ import { BatchPromotion } from '@shared/schemas/promotionSchema'; -import { Rank } from '@shared/types/rank' +import { PagedData } from '@shared/types/pagination'; +import { PromotionSummary, Rank } from '@shared/types/rank' // @ts-ignore const addr = import.meta.env.VITE_APIHOST; export async function getAllRanks(): Promise { - const res = await fetch(`${addr}/ranks`, { - credentials: 'include' - }) + const res = await fetch(`${addr}/ranks`, { + credentials: 'include' + }) - if (res.ok) { - return res.json() - } else { - console.error("Something went wrong approving the application") - } + if (res.ok) { + return res.json() + } else { + console.error("Something went wrong approving the application") + } } -// Placeholder: submit a rank change export async function submitRankChange(promo: BatchPromotion) { - const res = await fetch(`${addr}/memberRanks`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - credentials: 'include', - body: JSON.stringify(promo), - }) + const res = await fetch(`${addr}/memberRanks`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: 'include', + body: JSON.stringify(promo), + }) - if (res.ok) { - return - } else { - throw new Error("Failed to submit rank change: Server error"); - } + if (res.ok) { + return + } else { + throw new Error("Failed to submit rank change: Server error"); + } +} + +export async function getPromoHistory(page?: number, pageSize?: number): Promise> { + const params = new URLSearchParams(); + + if (page !== undefined) { + params.set("page", page.toString()); + } + + if (pageSize !== undefined) { + params.set("pageSize", pageSize.toString()); + } + + return fetch(`${addr}/memberRanks?${params}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: 'include', + }).then((res) => { + if (res.ok) { + return res.json(); + } else { + return []; + } + }); } \ No newline at end of file diff --git a/ui/src/components/promotions/promotionList.vue b/ui/src/components/promotions/promotionList.vue index a8ccb77..f0d651f 100644 --- a/ui/src/components/promotions/promotionList.vue +++ b/ui/src/components/promotions/promotionList.vue @@ -1,6 +1,44 @@