diff --git a/api/src/index.ts b/api/src/index.ts index cfdc719..9a61817 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -102,6 +102,7 @@ import { roles, memberRoles } from './routes/roles'; import { courseRouter, eventRouter } from './routes/course'; import { calendarRouter } from './routes/calendar'; import { docsRouter } from './routes/docs'; +import { units } from './routes/units'; app.use('/application', applicationRouter); app.use('/ranks', ranks); @@ -115,6 +116,7 @@ app.use('/memberRoles', memberRoles) app.use('/course', courseRouter) app.use('/courseEvent', eventRouter) app.use('/calendar', calendarRouter) +app.use('/units', units) app.use('/docs', docsRouter) app.use('/', authRouter) diff --git a/api/src/routes/members.ts b/api/src/routes/members.ts index f7bdd15..0d47c0f 100644 --- a/api/src/routes/members.ts +++ b/api/src/routes/members.ts @@ -5,12 +5,16 @@ import { Request, Response } from 'express'; import pool from '../db'; import { requireLogin, requireMemberState, requireRole } from '../middleware/auth'; import { getUserActiveLOA } from '../services/db/loaService'; -import { getAllMembersLite, getMemberSettings, getMembersFull, getMembersLite, getUserData, getUserState, setUserSettings } from '../services/db/memberService'; +import { getAllMembersLite, getMemberSettings, getMembersFull, getMembersLite, getUserData, getUserState, setUserSettings, getFilteredMembers, setUserState } from '../services/db/memberService'; import { getUserRoles } from '../services/db/rolesService'; import { memberSettings, MemberState, myData } from '@app/shared/types/member'; +import { Discharge } from '@app/shared/schemas/dischargeSchema'; import { Performance } from 'perf_hooks'; import { logger } from '../services/logging/logger'; +import { memberCache } from './auth'; +import { cancelLatestRank } from '../services/db/rankService'; +import { cancelLatestUnit } from '../services/db/unitService'; //get all users router.get('/', [requireLogin, requireMemberState(MemberState.Member)], async (req, res) => { @@ -42,6 +46,27 @@ router.get('/', [requireLogin, requireMemberState(MemberState.Member)], async (r } }); +router.get('/filtered', [requireLogin, requireMemberState(MemberState.Member)], async (req, res) => { + try { + // Extract Query Parameters + const page = parseInt(req.query.page as string) || 1; + const pageSize = parseInt(req.query.pageSize as string) || 15; + const search = req.query.search as string | undefined; + const status = req.query.status as string | undefined; + const unitId = req.query.unitId as string | undefined; + + // Call the service function + const result = await getFilteredMembers(page, pageSize, search, status, unitId); + + return res.status(200).json(result); + } catch (error) { + logger.error('app', 'Failed to get filtered users', { + error: error instanceof Error ? error.message : String(error), + }); + return res.status(500).json({ error: 'Failed to fetch users' }); + } +}); + router.get('/me', [requireLogin], async (req: Request, res) => { if (!req.user) return res.sendStatus(401); @@ -211,5 +236,32 @@ router.put('/:id/displayname', async (req, res) => { return res.status(501); }); +//discharge member +router.post('/discharge', [requireLogin, requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req: Request, res: Response) => { + try { + var con = await pool.getConnection(); + + con.beginTransaction(); + + var data: Discharge = req.body; + setUserState(data.userID, MemberState.Retired, con); + cancelLatestRank(data.userID, con); + cancelLatestUnit(data.userID, con); + con.commit(); + memberCache.Invalidate(data.userID); + + res.sendStatus(200); + } catch (error) { + logger.error('app', 'Failed to discharge user', { + data: data, + caller: req.user.id, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) + } finally { + if (con) + con.release(); + } +}); export const memberRouter = router; diff --git a/api/src/routes/units.ts b/api/src/routes/units.ts new file mode 100644 index 0000000..e339a8a --- /dev/null +++ b/api/src/routes/units.ts @@ -0,0 +1,29 @@ +import express = require('express'); +const unitsRouter = express.Router(); + +import pool from '../db'; +import { requireLogin } from '../middleware/auth'; +import { logger } from '../services/logging/logger'; +import { Unit } from '@app/shared/types/units'; + +unitsRouter.use(requireLogin); + +//get all units +unitsRouter.get('/', async (req, res) => { + try { + const result: Unit[] = await pool.query('SELECT * FROM units WHERE active = 1;'); + res.json(result); + } catch (error) { + logger.error( + 'app', + 'Failed to get all units', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + ); + res.sendStatus(500); + } +}); + +export const units = unitsRouter; diff --git a/api/src/services/db/memberService.ts b/api/src/services/db/memberService.ts index a0776d3..29f8737 100644 --- a/api/src/services/db/memberService.ts +++ b/api/src/services/db/memberService.ts @@ -1,8 +1,97 @@ import { Role } from "@app/shared/types/roles"; import pool from "../../db"; -import { Member, MemberCardDetails, MemberLight, memberSettings, MemberState } from '@app/shared/types/member' +import { Member, MemberCardDetails, MemberLight, memberSettings, MemberState, PaginatedMembers } from '@app/shared/types/member' import { logger } from "../logging/logger"; import { memberCache } from "../../routes/auth"; +import * as mariadb from 'mariadb'; + +export async function getFilteredMembers( + page: number = 1, + pageSize: number = 15, + search?: string, + status?: string, + unitId?: string +): Promise { + try { + const offset = (page - 1) * pageSize; + const whereClauses: string[] = []; + const params: any[] = []; + + if (status && status !== 'all') { + whereClauses.push(`m.state = ?`); + params.push(status); + } + + if (search) { + whereClauses.push(`v.member_name LIKE ?`); + params.push(`%${search}%`); + } + + if (unitId && unitId !== 'all') { + whereClauses.push(`v.unit = ?`); + params.push(unitId); + } + + const whereClause = whereClauses.length > 0 + ? ` WHERE ${whereClauses.join(' AND ')}` + : ''; + + // COUNT QUERY + const countQuery = `SELECT COUNT(*) as total FROM view_member_rank_unit_status_latest v INNER JOIN members m ON v.member_id = m.id ${whereClause}`; + const [countResults]: any[] = await pool.query(countQuery, params); + const total = Number(countResults?.total) || 0; + + // DATA QUERY + const dataQuery = ` + SELECT + v.*, + CASE + WHEN EXISTS ( + SELECT 1 FROM leave_of_absences l + WHERE l.member_id = v.member_id + AND l.deleted = 0 + AND UTC_TIMESTAMP() BETWEEN l.start_date AND l.end_date + ) THEN 1 ELSE 0 + END AS on_loa + FROM view_member_rank_unit_status_latest v + INNER JOIN members m ON v.member_id = m.id + ${whereClause} -- Added back correctly + ORDER BY v.member_name ASC + LIMIT ? OFFSET ? + `; + + const rows: any[] = await pool.query(dataQuery, [...params, pageSize, offset]); + + // Map rows to Member type + const members: Member[] = rows.map(row => ({ + member_id: Number(row.member_id), + member_name: row.member_name, + displayName: row.displayName, + rank: row.rank, + rank_date: row.rank_date, + unit: row.unit, + unit_date: row.unit_date, + status: row.status, + status_date: row.status_date, + loa_until: row.loa_until ? new Date(row.loa_until) : undefined, + })); + + return { + data: members, + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }; + } catch (error) { + logger.error('app', 'Error fetching filtered members', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} export async function getUserData(userID: number): Promise { const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id = ?`; @@ -10,12 +99,12 @@ export async function getUserData(userID: number): Promise { return res[0] ?? null; } -export async function setUserState(userID: number, state: MemberState) { +export async function setUserState(userID: number, state: MemberState, con: mariadb.Pool | mariadb.Connection = pool) { try { const sql = `UPDATE members SET state = ? WHERE id = ?;`; - return await pool.query(sql, [state, userID]); + return await con.query(sql, [state, userID]); } catch (error) { logger.error('app', 'Error setting user state', error); } finally { diff --git a/api/src/services/db/rankService.ts b/api/src/services/db/rankService.ts index 4bffdde..427a088 100644 --- a/api/src/services/db/rankService.ts +++ b/api/src/services/db/rankService.ts @@ -3,6 +3,7 @@ import { PromotionDetails, PromotionSummary } from "@app/shared/types/rank" import pool from "../../db"; import { PagedData } from "@app/shared/types/pagination"; import { toDate, toDateIgnoreZone, toDateTime } from "@app/shared/utils/time"; +import * as mariadb from 'mariadb'; export async function getAllRanks() { const rows = await pool.query( @@ -105,4 +106,14 @@ export async function getPromotionsOnDay(day: Date): Promise let batchPromotion = await pool.query(sql, [dayString]) as PromotionDetails[]; return batchPromotion; +} + +export async function cancelLatestRank(userID: number, con: mariadb.Pool | mariadb.Connection = pool): Promise { + try { + let sql = `CALL sp_end_member_rank(?,NOW())`; + con.query(sql, [userID]); + return true; + } catch (error) { + throw error; + } } \ No newline at end of file diff --git a/api/src/services/db/unitService.ts b/api/src/services/db/unitService.ts new file mode 100644 index 0000000..9d1b1f0 --- /dev/null +++ b/api/src/services/db/unitService.ts @@ -0,0 +1,13 @@ +import pool from "../../db"; +import * as mariadb from 'mariadb'; + + +export async function cancelLatestUnit(userID: number, con: mariadb.Pool | mariadb.Connection = pool): Promise { + try { + let sql = `CALL sp_end_member_unit(?,NOW())`; + con.query(sql, [userID]); + return true; + } catch (error) { + throw error; + } +} \ No newline at end of file diff --git a/shared/schemas/dischargeSchema.ts b/shared/schemas/dischargeSchema.ts new file mode 100644 index 0000000..6b44e01 --- /dev/null +++ b/shared/schemas/dischargeSchema.ts @@ -0,0 +1,10 @@ +import z from "zod"; + +export const dischargeSchema = z.object({ + reason: z.string().min(1, "Please provide a valid reason for discharge").max(200), + // effectiveDate: z.string().min(1, "Date is required"), +}) + +export type Discharge = z.infer & { + userID: number; +}; \ No newline at end of file diff --git a/shared/types/member.ts b/shared/types/member.ts index eea7f85..1cda2dd 100644 --- a/shared/types/member.ts +++ b/shared/types/member.ts @@ -1,10 +1,13 @@ import { LOARequest } from "./loa"; import { Role } from "./roles"; +import { PagedData } from "./pagination"; export interface memberSettings { displayName: string; } +export type PaginatedMembers = PagedData; + export enum MemberState { Guest = "guest", Applicant = "applicant", diff --git a/shared/types/units.ts b/shared/types/units.ts new file mode 100644 index 0000000..11866c6 --- /dev/null +++ b/shared/types/units.ts @@ -0,0 +1,7 @@ +export interface Unit { + id: number; + name: string; + description?: string; + active: boolean; + color?: string; +} \ No newline at end of file diff --git a/ui/src/api/member.ts b/ui/src/api/member.ts index b6aeb47..9d06a5b 100644 --- a/ui/src/api/member.ts +++ b/ui/src/api/member.ts @@ -1,4 +1,5 @@ -import { memberSettings, Member, MemberLight, MemberCardDetails } from "@shared/types/member"; +import { Discharge } from "@shared/schemas/dischargeSchema"; +import { memberSettings, Member, MemberLight, MemberCardDetails, PaginatedMembers } from "@shared/types/member"; // @ts-ignore const addr = import.meta.env.VITE_APIHOST; @@ -13,6 +14,33 @@ export async function getMembers(): Promise { return response.json(); } +export async function getMembersFiltered(params: { + page?: number; + pageSize?: number; + search?: string; + status?: string; + unitId?: string; +} = {}): Promise { + + // Construct the query string dynamically + const query = new URLSearchParams(); + if (params.page) query.append('page', params.page.toString()); + if (params.pageSize) query.append('pageSize', params.pageSize.toString()); + if (params.search) query.append('search', params.search); + if (params.status && params.status !== 'all') query.append('status', params.status); + if (params.unitId && params.unitId !== 'all') query.append('unitId', params.unitId); + + const response = await fetch(`${addr}/members/filtered?${query.toString()}`, { + credentials: 'include' + }); + + if (!response.ok) { + throw new Error("Failed to fetch members"); + } + + return response.json(); +} + export async function getMemberSettings(): Promise { const response = await fetch(`${addr}/members/settings`, { credentials: 'include' @@ -87,4 +115,24 @@ export async function getFullMembers(ids: number[]): Promise { + const response = await fetch(`${addr}/members/discharge`, { + credentials: 'include', + method: 'POST', + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data) + }); + if (!response.ok) { + throw new Error("Failed to discharge member"); + } + return true; } \ No newline at end of file diff --git a/ui/src/api/units.ts b/ui/src/api/units.ts new file mode 100644 index 0000000..84b0d8c --- /dev/null +++ b/ui/src/api/units.ts @@ -0,0 +1,15 @@ +import { memberSettings, Member, MemberLight, MemberCardDetails } from "@shared/types/member"; +import { Unit } from "@shared/types/units"; + +// @ts-ignore +const addr = import.meta.env.VITE_APIHOST; + +export async function getUnits(): Promise { + const response = await fetch(`${addr}/units`, { + credentials: 'include' + }); + if (!response.ok) { + throw new Error("Failed to fetch units"); + } + return response.json(); +} \ No newline at end of file diff --git a/ui/src/components/Navigation/Navbar.vue b/ui/src/components/Navigation/Navbar.vue index 9aebc3d..b90d84d 100644 --- a/ui/src/components/Navigation/Navbar.vue +++ b/ui/src/components/Navigation/Navbar.vue @@ -138,6 +138,12 @@ function blurAfter() { + + + Member Management + + + @@ -146,13 +152,6 @@ function blurAfter() { - - - diff --git a/ui/src/components/members/DischargeMember.vue b/ui/src/components/members/DischargeMember.vue new file mode 100644 index 0000000..f5a77a3 --- /dev/null +++ b/ui/src/components/members/DischargeMember.vue @@ -0,0 +1,91 @@ + + +