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..cc10f94 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 { PromotionDetails, 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,75 @@ 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 + WHERE reason = 'Rank Change' + 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 + WHERE reason = 'Rank Change' + ) 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 { + + const dayString = toDateTime(day); + + // SQL query to fetch all records from members_unit for the specified day + let sql = ` + SELECT + mr.member_id, + mr.created_by_id, + r.short_name + FROM members_ranks AS mr + LEFT JOIN ranks AS r ON r.id = mr.rank_id + WHERE DATE(mr.start_date) = ? && mr.reason = 'Rank Change' + ORDER BY mr.start_date ASC; + `; + + let batchPromotion = await pool.query(sql, [dayString]) as PromotionDetails[]; + + return batchPromotion; +} \ No newline at end of file diff --git a/shared/schemas/promotionSchema.ts b/shared/schemas/promotionSchema.ts new file mode 100644 index 0000000..6c78b75 --- /dev/null +++ b/shared/schemas/promotionSchema.ts @@ -0,0 +1,32 @@ +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", + }), +}); + +export const batchPromotionSchema = z.object({ + promotions: z.array(batchPromotionMemberSchema, { message: "At least one promotion is required" }).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", + }); + } + }); + }); + + +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 new file mode 100644 index 0000000..6ec4f3a --- /dev/null +++ b/shared/types/rank.ts @@ -0,0 +1,17 @@ +export type Rank = { + id: number + name: string + short_name: string + category: string + sortOrder: number +} + +export interface PromotionSummary { + entry_day: Date; +} + +export interface PromotionDetails { + member_id: number; + short_name: string; + created_by_id: number; +} \ No newline at end of file diff --git a/ui/src/api/rank.ts b/ui/src/api/rank.ts index 1fdcf09..e391a83 100644 --- a/ui/src/api/rank.ts +++ b/ui/src/api/rank.ts @@ -1,38 +1,74 @@ -export type Rank = { - id: number - name: string - short_name: string - sortOrder: number -} +import { BatchPromotion, BatchPromotionMember } from '@shared/schemas/promotionSchema'; +import { PagedData } from '@shared/types/pagination'; +import { PromotionDetails, PromotionSummary, 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`) - - if (res.ok) { - return res.json() - } else { - console.error("Something went wrong approving the application") - } +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 -export async function submitRankChange(member_id: number, rank_id: number, date: string): Promise<{ ok: boolean }> { - const res = await fetch(`${addr}/memberRanks`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ change: { member_id, rank_id, date } }), - }) +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), + }) - if (res.ok) { - return { ok: true } - } else { - console.error("Failed to submit rank change") - return { ok: false } - } + 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 []; + } + }); +} + +export async function getPromotionsOnDay(day: Date): Promise { + console.log(day.toISOString()); + const res = await fetch(`${addr}/memberRanks/${day.toISOString()}`, { + credentials: 'include', + }) + + if (res.ok) { + return await res.json(); + } else { + 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 new file mode 100644 index 0000000..dc7b10f --- /dev/null +++ b/ui/src/components/promotions/promotionForm.vue @@ -0,0 +1,269 @@ + + + diff --git a/ui/src/components/promotions/promotionList.vue b/ui/src/components/promotions/promotionList.vue new file mode 100644 index 0000000..d67c859 --- /dev/null +++ b/ui/src/components/promotions/promotionList.vue @@ -0,0 +1,151 @@ + + diff --git a/ui/src/components/promotions/promotionListDay.vue b/ui/src/components/promotions/promotionListDay.vue new file mode 100644 index 0000000..3e4dc36 --- /dev/null +++ b/ui/src/components/promotions/promotionListDay.vue @@ -0,0 +1,52 @@ + + + \ No newline at end of file diff --git a/ui/src/pages/RankChange.vue b/ui/src/pages/RankChange.vue index 565d579..5df78c7 100644 --- a/ui/src/pages/RankChange.vue +++ b/ui/src/pages/RankChange.vue @@ -1,115 +1,24 @@ \ No newline at end of file +