Revived the page! He's baaaaack
This commit is contained in:
@@ -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
|
||||
const addr = import.meta.env.VITE_APIHOST;
|
||||
@@ -13,6 +13,33 @@ export async function getMembers(): Promise<Member[]> {
|
||||
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> {
|
||||
const response = await fetch(`${addr}/members/settings`, {
|
||||
credentials: 'include'
|
||||
|
||||
15
ui/src/api/units.ts
Normal file
15
ui/src/api/units.ts
Normal 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();
|
||||
}
|
||||
@@ -1,117 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
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 { ref, computed, onMounted, watch } from "vue";
|
||||
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 LoaForm from "@/components/loa/loaForm.vue";
|
||||
import Spinner from "@/components/ui/spinner/Spinner.vue";
|
||||
|
||||
const members = ref<Member[]>([]);
|
||||
// --- State ---
|
||||
const router = useRouter();
|
||||
|
||||
const loaded = ref(false);
|
||||
const fetchMembers = async () => {
|
||||
members.value = await getMembers();
|
||||
loaded.value = true;
|
||||
};
|
||||
|
||||
function viewMember(id) {
|
||||
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 members = ref<Member[]>([]);
|
||||
const units = ref<Unit[]>([]);
|
||||
const isLoaded = ref(false);
|
||||
const pagination = ref<PaginationType>({
|
||||
page: 1,
|
||||
pageSize: 15,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<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 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>
|
||||
<Table class="h-10">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[100px]">
|
||||
Member
|
||||
</TableHead>
|
||||
<TableHead>Rank</TableHead>
|
||||
<TableHead>Unit</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
<div class="min-h-[500px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="hover:bg-transparent border-b">
|
||||
<TableHead class="w-[200px]">Member</TableHead>
|
||||
<TableHead>Rank</TableHead>
|
||||
<TableHead>Unit</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Notes</TableHead>
|
||||
<TableHead class="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto scrollbar-themed">
|
||||
<Table v-if="loaded">
|
||||
<TableBody>
|
||||
<TableRow v-for="member in searchedMembers" :key="member.member_id">
|
||||
<TableCell class="font-medium">
|
||||
{{ member.member_name }}
|
||||
</TableCell>
|
||||
<TableCell>{{ member.rank }}</TableCell>
|
||||
<TableCell>{{ member.unit }}</TableCell>
|
||||
<TableCell>{{ member.status }}</TableCell>
|
||||
<TableCell>
|
||||
<Badge v-if="member.loa_until">On LOA</Badge>
|
||||
</TableCell>
|
||||
<TableCell @click.stop="" class="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="cursor-pointer">
|
||||
<Ellipsis></Ellipsis>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<!-- <DropdownMenuItem>Change Rank</DropdownMenuItem>
|
||||
<DropdownMenuItem>Transfer</DropdownMenuItem> -->
|
||||
<DropdownMenuItem :variant="'destructive'">Discharge</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<template v-if="isLoaded">
|
||||
<TableRow v-for="member in paginatedMembers" :key="member.member_id"
|
||||
class="group cursor-pointer hover:bg-muted/30 transition-colors">
|
||||
<TableCell class="font-medium py-4">{{ member.member_name }}</TableCell>
|
||||
<TableCell>{{ member.rank }}</TableCell>
|
||||
<TableCell>{{ member.unit }}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" class="capitalize font-normal">{{ member.status }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge v-if="member.loa_until" variant="secondary"
|
||||
class="bg-yellow-500/10 text-yellow-600 border-none">On LOA</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-right" @click.stop>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="hover:bg-muted">
|
||||
<Ellipsis class="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div v-else class="flex justify-center my-[30vh]">
|
||||
<Spinner class="size-10"></Spinner>
|
||||
</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>
|
||||
</template>
|
||||
Reference in New Issue
Block a user