Revived the page! He's baaaaack

This commit is contained in:
2026-01-27 10:01:41 -05:00
parent 7c7cbef3f3
commit 67562f56aa
9 changed files with 469 additions and 95 deletions

View File

@@ -102,6 +102,7 @@ import { roles, memberRoles } from './routes/roles';
import { courseRouter, eventRouter } from './routes/course'; import { courseRouter, eventRouter } from './routes/course';
import { calendarRouter } from './routes/calendar'; import { calendarRouter } from './routes/calendar';
import { docsRouter } from './routes/docs'; import { docsRouter } from './routes/docs';
import { units } from './routes/units';
app.use('/application', applicationRouter); app.use('/application', applicationRouter);
app.use('/ranks', ranks); app.use('/ranks', ranks);
@@ -115,6 +116,7 @@ app.use('/memberRoles', memberRoles)
app.use('/course', courseRouter) app.use('/course', courseRouter)
app.use('/courseEvent', eventRouter) app.use('/courseEvent', eventRouter)
app.use('/calendar', calendarRouter) app.use('/calendar', calendarRouter)
app.use('/units', units)
app.use('/docs', docsRouter) app.use('/docs', docsRouter)
app.use('/', authRouter) app.use('/', authRouter)

View File

@@ -5,7 +5,7 @@ import { Request, Response } from 'express';
import pool from '../db'; import pool from '../db';
import { requireLogin, requireMemberState, requireRole } from '../middleware/auth'; import { requireLogin, requireMemberState, requireRole } from '../middleware/auth';
import { getUserActiveLOA } from '../services/db/loaService'; 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 } from '../services/db/memberService';
import { getUserRoles } from '../services/db/rolesService'; import { getUserRoles } from '../services/db/rolesService';
import { memberSettings, MemberState, myData } from '@app/shared/types/member'; import { memberSettings, MemberState, myData } from '@app/shared/types/member';
@@ -42,6 +42,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) => { router.get('/me', [requireLogin], async (req: Request, res) => {
if (!req.user) return res.sendStatus(401); if (!req.user) return res.sendStatus(401);

29
api/src/routes/units.ts Normal file
View File

@@ -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;

View File

@@ -1,9 +1,97 @@
import { Role } from "@app/shared/types/roles"; import { Role } from "@app/shared/types/roles";
import pool from "../../db"; 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 { logger } from "../logging/logger";
import { memberCache } from "../../routes/auth"; import { memberCache } from "../../routes/auth";
export async function getFilteredMembers(
page: number = 1,
pageSize: number = 15,
search?: string,
status?: string,
unitId?: string
): Promise<PaginatedMembers> {
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<Member> { export async function getUserData(userID: number): Promise<Member> {
const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id = ?`; const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id = ?`;
const res: Member = await pool.query(sql, [userID]); const res: Member = await pool.query(sql, [userID]);

View File

@@ -1,10 +1,13 @@
import { LOARequest } from "./loa"; import { LOARequest } from "./loa";
import { Role } from "./roles"; import { Role } from "./roles";
import { PagedData } from "./pagination";
export interface memberSettings { export interface memberSettings {
displayName: string; displayName: string;
} }
export type PaginatedMembers = PagedData<Member>;
export enum MemberState { export enum MemberState {
Guest = "guest", Guest = "guest",
Applicant = "applicant", Applicant = "applicant",

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

@@ -0,0 +1,7 @@
export interface Unit {
id: number;
name: string;
description?: string;
active: boolean;
color?: string;
}

View File

@@ -1,4 +1,4 @@
import { memberSettings, Member, MemberLight, MemberCardDetails } from "@shared/types/member"; import { memberSettings, Member, MemberLight, MemberCardDetails, PaginatedMembers } from "@shared/types/member";
// @ts-ignore // @ts-ignore
const addr = import.meta.env.VITE_APIHOST; const addr = import.meta.env.VITE_APIHOST;
@@ -13,6 +13,33 @@ export async function getMembers(): Promise<Member[]> {
return response.json(); return response.json();
} }
export async function getMembersFiltered(params: {
page?: number;
pageSize?: number;
search?: string;
status?: string;
unitId?: string;
} = {}): Promise<PaginatedMembers> {
// 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<memberSettings> { export async function getMemberSettings(): Promise<memberSettings> {
const response = await fetch(`${addr}/members/settings`, { const response = await fetch(`${addr}/members/settings`, {
credentials: 'include' credentials: 'include'

15
ui/src/api/units.ts Normal file
View File

@@ -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<Unit[]> {
const response = await fetch(`${addr}/units`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error("Failed to fetch units");
}
return response.json();
}

View File

@@ -1,117 +1,299 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { ref, computed, onMounted, watch } from "vue";
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import Badge from "@/components/ui/badge/Badge.vue";
import { computed, ref } from "vue";
import { getMembers } from "@/api/member";
import { Member } from "@shared/types/member";
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { Ellipsis } from "lucide-vue-next"; import {
Ellipsis, Search, Trash2, UserX,
X,
} from "lucide-vue-next";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
// API & Types
import { getMembersFiltered } from "@/api/member";
import { getUnits } from "@/api/units";
import type { Member } from "@shared/types/member";
import { MemberState } from "@shared/types/member";
import type { Unit } from "@shared/types/units";
import type { pagination as PaginationType } from "@shared/types/pagination";
// UI Components
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from "@/components/ui/button";
import Badge from "@/components/ui/badge/Badge.vue";
import Input from "@/components/ui/input/Input.vue"; import Input from "@/components/ui/input/Input.vue";
import LoaForm from "@/components/loa/loaForm.vue";
import Spinner from "@/components/ui/spinner/Spinner.vue"; import Spinner from "@/components/ui/spinner/Spinner.vue";
const members = ref<Member[]>([]); // --- State ---
const router = useRouter(); const router = useRouter();
const members = ref<Member[]>([]);
const loaded = ref(false); const units = ref<Unit[]>([]);
const fetchMembers = async () => { const isLoaded = ref(false);
members.value = await getMembers(); const pagination = ref<PaginationType>({
loaded.value = true; page: 1,
}; pageSize: 15,
total: 0,
function viewMember(id) { totalPages: 0,
router.push(`/member/${id}`)
}
fetchMembers();
const searchVal = ref<string>("");
const searchedMembers = computed(() => {
return members.value.filter(member => member.member_name.toLowerCase().includes(searchVal.value.toLowerCase()));
}); });
const filters = ref({
search: "",
status: "all",
unitId: "all"
});
// Pagination State
const pageNum = ref(1);
const pageSize = ref(15);
const pageSizeOptions = [10, 15, 30];
const MEMBER_STATUSES = Object.values(MemberState);
// --- Methods ---
const fetchMembers = async () => {
isLoaded.value = false;
try {
const result = await getMembersFiltered({
page: pageNum.value,
pageSize: pageSize.value,
search: filters.value.search || undefined,
status: filters.value.status,
unitId: filters.value.unitId,
});
members.value = result.data;
pagination.value = result.pagination;
} catch (error) {
console.error('Failed to fetch members:', error);
members.value = [];
} finally {
isLoaded.value = true;
}
};
const fetchUnits = async () => {
try {
units.value = await getUnits();
} catch (error) {
console.error('Failed to fetch units:', error);
}
};
const navigateToMember = (id: string | number) => router.push(`/member/${id}`);
const setPage = (num: number) => {
pageNum.value = num;
};
const setPageSize = (size: number) => {
pageSize.value = size;
pageNum.value = 1;
};
// --- Computed ---
const paginatedMembers = computed(() => members.value);
const totalItems = computed(() => pagination.value.total);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Watch pagination (Immediate)
watch([pageNum, pageSize], () => {
if (debounceTimer) clearTimeout(debounceTimer);
fetchMembers();
});
// Watch filters (Debounced)
watch(filters, () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
fetchMembers();
}, 300);
}, { deep: true });
function clearFilters() {
filters.value = {
search: "",
status: "all",
unitId: "all"
}
}
onMounted(() => {
fetchUnits();
fetchMembers();
});
</script> </script>
<template> <template>
<div class="mx-auto max-w-7xl w-full py-10 px-4">
<div class="flex flex-col gap-2">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="space-y-0.5">
<h1 class="text-2xl font-bold tracking-tight text-foreground">Member Management</h1>
<p class="text-muted-foreground text-sm">Directory of all personnel and unit assignments.</p>
</div>
<!-- table menu -->
<div class="w-4xl mx-auto">
<div class="flex flex-row items-center justify-between my-5 mt-7">
<div class="flex items-center justify-between">
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">Member Management</p>
</div> </div>
<div class="flex justify-between items-center gap-3 mb-4">
<Input v-model="searchVal" placeholder="Search Members..."></Input> <div class="flex flex-col gap-4 sm:flex-row sm:items-center justify-between border-y border-border/40 py-3">
<div class="flex items-center gap-2">
<Select v-model="filters.status">
<SelectTrigger
class="h-8 w-fit min-w-[110px] border-none bg-muted/50 hover:bg-muted transition-colors text-xs">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem v-for="s in MEMBER_STATUSES" :key="s" :value="s">
<span class="capitalize">{{ s }}</span>
</SelectItem>
</SelectContent>
</Select>
<Select v-model="filters.unitId">
<SelectTrigger
class="h-8 w-fit min-w-[110px] border-none bg-muted/50 hover:bg-muted transition-colors text-xs">
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Units</SelectItem>
<SelectItem v-for="u in units" :key="u.id" :value="u.name">
{{ u.name }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="filters.status !== 'all' || filters.unitId !== 'all'"
class="h-4 w-[1px] bg-border mx-1" />
<Button v-if="filters.status !== 'all' || filters.unitId !== 'all'" variant="ghost" size="sm"
class="h-8 px-2 text-xs text-muted-foreground"
@click="filters.status = 'all'; filters.unitId = 'all'">
Clear Filters
</Button>
</div>
<div class="flex items-center gap-2">
<div class="relative w-full sm:w-[260px]">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" />
<Input v-model="filters.search" placeholder="Search members..."
class="h-8 pl-9 pr-8 bg-background text-xs focus-visible:ring-1" />
<button v-if="filters.search" @click="filters.search = ''"
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/60 hover:text-foreground transition-colors">
<X class="size-3.5" />
</button>
</div>
</div>
</div> </div>
</div> </div>
<Table class="h-10"> <div class="min-h-[500px]">
<TableHeader> <Table>
<TableRow> <TableHeader>
<TableHead class="w-[100px]"> <TableRow class="hover:bg-transparent border-b">
Member <TableHead class="w-[200px]">Member</TableHead>
</TableHead> <TableHead>Rank</TableHead>
<TableHead>Rank</TableHead> <TableHead>Unit</TableHead>
<TableHead>Unit</TableHead> <TableHead>Status</TableHead>
<TableHead>Status</TableHead> <TableHead>Notes</TableHead>
</TableRow> <TableHead class="text-right">Actions</TableHead>
</TableHeader> </TableRow>
</Table> </TableHeader>
<div class="max-h-[60vh] overflow-y-auto scrollbar-themed">
<Table v-if="loaded">
<TableBody> <TableBody>
<TableRow v-for="member in searchedMembers" :key="member.member_id"> <template v-if="isLoaded">
<TableCell class="font-medium"> <TableRow v-for="member in paginatedMembers" :key="member.member_id"
{{ member.member_name }} class="group cursor-pointer hover:bg-muted/30 transition-colors">
</TableCell> <TableCell class="font-medium py-4">{{ member.member_name }}</TableCell>
<TableCell>{{ member.rank }}</TableCell> <TableCell>{{ member.rank }}</TableCell>
<TableCell>{{ member.unit }}</TableCell> <TableCell>{{ member.unit }}</TableCell>
<TableCell>{{ member.status }}</TableCell> <TableCell>
<TableCell> <Badge variant="outline" class="capitalize font-normal">{{ member.status }}</Badge>
<Badge v-if="member.loa_until">On LOA</Badge> </TableCell>
</TableCell> <TableCell>
<TableCell @click.stop="" class="text-right"> <Badge v-if="member.loa_until" variant="secondary"
<DropdownMenu> class="bg-yellow-500/10 text-yellow-600 border-none">On LOA</Badge>
<DropdownMenuTrigger class="cursor-pointer"> </TableCell>
<Ellipsis></Ellipsis> <TableCell class="text-right" @click.stop>
</DropdownMenuTrigger> <DropdownMenu>
<DropdownMenuContent> <DropdownMenuTrigger as-child>
<!-- <DropdownMenuItem>Change Rank</DropdownMenuItem> <Button variant="ghost" size="icon" class="hover:bg-muted">
<DropdownMenuItem>Transfer</DropdownMenuItem> --> <Ellipsis class="size-5" />
<DropdownMenuItem :variant="'destructive'">Discharge</DropdownMenuItem> </Button>
</DropdownMenuContent> </DropdownMenuTrigger>
</DropdownMenu> <DropdownMenuContent align="end" class="w-48">
<!-- <DropdownMenuItem @click="navigateToMember(member.member_id)">
View Profile
</DropdownMenuItem> -->
<DropdownMenuItem
class="text-destructive focus:bg-destructive focus:text-destructive-foreground font-medium">
Discharge Member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
<TableRow v-if="paginatedMembers.length === 0" class="hover:bg-transparent">
<TableCell colspan="6" class="h-64 text-center">
<div class="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<UserX class="size-10 opacity-20 mb-2" />
<p class="font-medium">No results found</p>
<p class="text-xs">Try adjusting your filters or search query.</p>
</div>
</TableCell>
</TableRow>
</template>
<TableRow v-else>
<TableCell colspan="6" class="h-64 text-center">
<div class="flex flex-col items-center justify-center gap-4 text-muted-foreground">
<Spinner class="size-8" />
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
<div v-else class="flex justify-center my-[30vh]">
<Spinner class="size-10"></Spinner>
</div>
</div> </div>
<div class="mt-8 flex flex-col sm:flex-row items-center justify-between gap-4 border-t pt-6">
<div class="flex items-center gap-3 text-sm">
<p class="text-muted-foreground text-nowrap">Items per page:</p>
<div class="flex bg-muted/50 p-1 rounded-md">
<button v-for="size in pageSizeOptions" :key="size" @click="setPageSize(size)"
class="px-3 py-1 rounded transition-all text-xs"
:class="pageSize === size ? 'bg-background shadow-sm font-bold' : 'hover:text-foreground opacity-60'">
{{ size }}
</button>
</div>
</div>
<Pagination v-if="totalItems > 0" :total="totalItems" :items-per-page="pageSize" :page="pageNum"
:sibling-count="1" @update:page="setPage">
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template v-for="(item, index) in items" :key="index">
<PaginationItem v-if="item.type === 'page'" :value="item.value"
:is-active="item.value === pageNum">
<Button variant="ghost" size="sm" :class="{ 'bg-muted': pageNum === item.value }"
@click="setPage(item.value)">
{{ item.value }}
</Button>
</PaginationItem>
<PaginationEllipsis v-else-if="item.type === 'ellipsis'" :key="`ellipsis-${index}`" />
</template>
<PaginationNext />
</PaginationContent>
</Pagination>
<p class="text-xs text-muted-foreground w-[100px] text-right">
Total: {{ totalItems }}
</p>
</div>
</div> </div>
</template> </template>