Merge remote-tracking branch 'Origin/main' into Mobile-Enhancements

This commit is contained in:
2026-03-21 19:48:49 -04:00
76 changed files with 117530 additions and 820 deletions

View File

@@ -11,6 +11,8 @@ import { useRouter, useRoute } from 'vue-router'
import { useCalendarEvents } from '@/composables/useCalendarEvents'
import { useCalendarNavigation } from '@/composables/useCalendarNavigation'
import { useUserStore } from '@/stores/user'
import { CalendarOptions } from '@fullcalendar/core'
import { MemberState } from '@shared/types/member'
const monthLabels = [
'January', 'February', 'March', 'April', 'May', 'June',
@@ -49,14 +51,14 @@ const dialogRef = ref<any>(null)
// NEW: handle day/time slot clicks to start creating an event
function onDateClick(arg: { dateStr: string }) {
if (!userStore.isLoggedIn) return;
if (userStore.state !== 'member') return;
if (userStore.state !== MemberState.Member) return;
dialogRef.value?.openDialog(arg.dateStr);
}
const calendarOptions = ref({
const calendarOptions = ref<CalendarOptions>({
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
height: '100%',
height: 'auto',
expandRows: true,
headerToolbar: {
left: '',
@@ -70,6 +72,7 @@ const calendarOptions = ref({
eventClick: onEventClick,
editable: false,
// force block-mode in dayGrid so we can lay it out on one line
eventDisplay: 'block',
@@ -155,8 +158,8 @@ onMounted(() => {
<div>
<CreateCalendarEvent ref="dialogRef" @reload="loadEvents(); eventViewRef.forceReload();"></CreateCalendarEvent>
<div class="flex">
<div class="flex-1 min-h-0 mt-5">
<div class="h-[80vh] min-h-0">
<div class="flex-1 min-h-0 mt-5" :class="{ 'hidden md:block': panelOpen }">
<div class="max-h-[80vh] overflow-y-auto min-h-0 scrollbar-themed p-2">
<!-- calendar header -->
<div class="flex items-center justify-between mx-5">
<!-- Left: title + pickers -->
@@ -196,7 +199,7 @@ onMounted(() => {
@click="goToday">
Today
</button>
<button v-if="userStore.isLoggedIn && userStore.state === 'member'"
<button v-if="userStore.isLoggedIn && userStore.state === MemberState.Member"
class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
@click="onCreateEvent">
<Plus class="h-4 w-4" />
@@ -208,50 +211,52 @@ onMounted(() => {
</div>
</div>
<aside v-if="panelOpen"
class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col overflow-auto scrollbar-themed"
class="w-screen 3xl:w-lg 2xl:w-md lg:border-l bg-card text-foreground flex flex-col overflow-auto scrollbar-themed"
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
<ViewCalendarEvent ref="eventViewRef" :key="currentEventID" @close="() => { router.push('/calendar'); }"
@reload="loadEvents()" @edit="(val) => { dialogRef.openDialog(null, 'edit', val) }">
@reload="loadEvents()" @load="calendarRef.getApi().updateSize()"
@edit="(val) => { dialogRef.openDialog(null, 'edit', val) }">
</ViewCalendarEvent>
</aside>
</div>
</div>
</template>
<style scoped>
/* Firefox */
.scrollbar-themed {
scrollbar-width: thin;
scrollbar-color: #555 #1f1f1f;
padding-right: 6px;
}
/* Chrome, Edge, Safari */
.scrollbar-themed::-webkit-scrollbar {
width: 10px;
/* slightly wider to allow padding look */
}
.scrollbar-themed::-webkit-scrollbar-track {
background: #1f1f1f;
margin-left: 6px;
/* ❗ adds space between content + scrollbar */
}
.scrollbar-themed::-webkit-scrollbar-thumb {
background: #555;
border-radius: 9999px;
}
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>
<style scoped>
/* ---------- Optional container "card" around the calendar ---------- */
/* Ensure the calendar fills the container properly */
:global(.fc) {
height: 100% !important;
--fc-page-bg-color: transparent;
--fc-neutral-bg-color: color-mix(in srgb, var(--color-foreground) 8%, transparent);
--fc-neutral-text-color: var(--color-muted-foreground);
--fc-border-color: var(--color-border);
--fc-button-bg-color: transparent;
--fc-button-border-color: var(--color-border);
--fc-button-hover-bg-color: var(--color-muted);
}
:global(.fc-theme-standard .fc-scrollgrid) {
border-radius: 8px;
overflow: hidden;
/* Rounds the corners of the grid */
border: 1px solid var(--color-border);
}
:global(.fc-daygrid-day-frame) {
display: flex;
flex-direction: column;
padding: 4px;
}
:global(.fc .fc-scroller-harness) {
background: transparent;
}
:global(.fc-daygrid-day-events) {
flex-grow: 1;
/* Pushes events to take up available space */
}
:global(.ev-pill.is-cancelled) {
@@ -297,6 +302,7 @@ onMounted(() => {
:global(.fc .fc-scrollgrid td),
:global(.fc .fc-scrollgrid th) {
border-color: var(--color-border);
background: var(--fc-page-bg-color);
}
/* ---------- Built-in toolbar (if you keep it) ---------- */
@@ -344,6 +350,7 @@ onMounted(() => {
text-decoration: none;
}
:global(.fc .fc-daygrid-day-top) {
padding: 8px 8px 0 8px;
}

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue';
import { bustUserCache } from '@/api/member';
import type { UserCacheBustResult } from '@shared/types/member';
import { ref } from 'vue';
const loading = ref(false);
const result = ref<UserCacheBustResult | null>(null);
const error = ref<string | null>(null);
async function onBustUserCache() {
loading.value = true;
error.value = null;
try {
result.value = await bustUserCache();
} catch (err) {
result.value = null;
error.value = err instanceof Error ? err.message : 'Failed to bust user cache';
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="max-w-3xl mx-auto pt-10 px-4">
<h1 class="scroll-m-20 text-2xl font-semibold tracking-tight">Developer Tools</h1>
<p class="mt-2 text-sm text-muted-foreground">
Use this page to recover from stale in-memory authentication data after manual database changes.
</p>
<div class="mt-6 rounded-lg border p-5 bg-card">
<p class="font-medium">Server User Cache</p>
<p class="text-sm text-muted-foreground mt-1">
This clears the API server's cached user session data so the next request reloads from the database.
</p>
<div class="mt-4 flex items-center gap-3">
<Button :disabled="loading" @click="onBustUserCache">
{{ loading ? 'Busting Cache...' : 'Bust User Cache' }}
</Button>
</div>
<p v-if="result" class="mt-4 text-sm text-green-700">
Cache busted successfully. Cleared {{ result.clearedEntries }} entr{{ result.clearedEntries === 1 ? 'y' : 'ies' }} at
{{ new Date(result.bustedAt).toLocaleString() }}.
</p>
<p v-if="error" class="mt-4 text-sm text-red-700">{{ error }}</p>
</div>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import { getWelcomeMessage } from '@/api/docs';
import { Button } from '@/components/ui/button'
import { useUserStore } from '@/stores/user'
import { MemberState } from '@shared/types/member';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'
@@ -14,7 +15,7 @@ function goToApplication() {
}
onMounted(async () => {
if (user.state == 'member') {
if (user.state == MemberState.Member) {
let policy = await getWelcomeMessage() as any;
welcomeRef.value.innerHTML = policy;
}
@@ -25,7 +26,7 @@ const welcomeRef = ref<HTMLElement>(null);
<template>
<div>
<div v-if="user.state == 'member'" class="mt-10">
<div v-if="user.state == MemberState.Member" class="mt-10">
<div ref="welcomeRef" class="bookstack-container">
<!-- bookstack -->
</div>

View File

@@ -1,90 +1,93 @@
<script setup lang="ts">
import ApplicationForm from '@/components/application/ApplicationForm.vue';
import Button from '@/components/ui/button/Button.vue';
import {
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from '@/components/ui/stepper'
import { useUserStore } from '@/stores/user';
import { Check, Circle, Dot, Users, X } from 'lucide-vue-next'
import { computed, ref } from 'vue';
import Application from './Application.vue';
import { restartApplication } from '@/api/application';
import ApplicationForm from '@/components/application/ApplicationForm.vue';
import Button from '@/components/ui/button/Button.vue';
import {
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from '@/components/ui/stepper'
import { useUserStore } from '@/stores/user';
import { Check, Circle, Dot, Users, X } from 'lucide-vue-next'
import { computed, ref } from 'vue';
import Application from './Application.vue';
import { restartApplication } from '@/api/application';
import { MemberState } from '@shared/types/member';
function goToLogin() {
const redirectUrl = encodeURIComponent(window.location.origin + '/join')
//@ts-ignore
const addr = import.meta.env.VITE_APIHOST;
window.location.href = `${addr}/login?redirect=${redirectUrl}`;
}
let userStore = useUserStore();
const steps = computed(() => {
const isDenied = userStore.state === 'denied'
return [
{
step: 1,
title: 'Create account',
description: 'Begin by setting up your account',
},
{
step: 2,
title: 'Submit application',
description: 'Provide a few details about yourself',
},
{
step: 3,
title: 'Application review',
description: 'Our team will review your submission',
},
{
step: 4,
title: isDenied ? 'Application denied' : 'Acceptance',
description: isDenied
? 'Your application was not approved'
: 'Get started with the 17th Rangers',
},
]
})
const currentStep = computed<number>(() => {
if (!userStore.isLoggedIn)
return 1;
switch (userStore.state) {
case "guest":
return 2;
break;
case "applicant":
return 3;
break;
case "member":
return 5;
break;
case "denied":
return 5;
break;
case "retired":
return 5;
break;
function goToLogin() {
const redirectUrl = encodeURIComponent(window.location.origin + '/join')
//@ts-ignore
const addr = import.meta.env.VITE_APIHOST;
window.location.href = `${addr}/login?redirect=${redirectUrl}`;
}
})
const finalPanel = ref<'app' | 'message'>('message');
let userStore = useUserStore();
const reloadKey = ref(0);
const steps = computed(() => {
const isDenied = userStore.state === MemberState.Denied
async function restartApp() {
await restartApplication();
await userStore.loadUser();
reloadKey.value++;
}
return [
{
step: 1,
title: 'Create account',
description: 'Begin by setting up your account',
},
{
step: 2,
title: 'Submit application',
description: 'Provide a few details about yourself',
},
{
step: 3,
title: 'Application review',
description: 'Our team will review your submission',
},
{
step: 4,
title: isDenied ? 'Application denied' : 'Acceptance',
description: isDenied
? 'Your application was not approved'
: 'Get started with the 17th Rangers',
},
]
})
const currentStep = computed<number>(() => {
if (!userStore.isLoggedIn)
return 1;
switch (userStore.state) {
case MemberState.Guest:
return 2;
break;
case MemberState.Applicant:
return 3;
break;
case MemberState.Member:
return 5;
break;
case MemberState.Denied:
return 5;
break;
case MemberState.Retired:
return 5;
case MemberState.Discharged:
return 5;
break;
}
})
const finalPanel = ref<'app' | 'message'>('message');
const reloadKey = ref(0);
async function restartApp() {
await restartApplication();
await userStore.loadUser();
reloadKey.value++;
}
</script>
<template>
@@ -104,7 +107,8 @@ async function restartApp() {
size="icon" class="z-10 rounded-full shrink-0"
:class="[state === 'active' && 'ring-2 ring-ring ring-offset-2 ring-offset-background']">
<template v-if="state === 'completed'">
<X v-if="step.step === 4 && userStore.state === 'denied'" class="size-5" />
<X v-if="step.step === 4 && userStore.state === MemberState.Denied"
class="size-5" />
<Check v-else class="size-5" />
</template>
<Circle v-if="state === 'active'" />
@@ -160,7 +164,7 @@ async function restartApp() {
</div>
<div v-if="finalPanel === 'message'">
<!-- Accepted message -->
<div v-if="userStore.state === 'member'">
<div v-if="userStore.state === MemberState.Member">
<h1 class="text-3xl sm:text-4xl font-bold mb-4 text-left">
Welcome to the 17th Ranger Battalion
</h1>
@@ -232,7 +236,7 @@ async function restartApp() {
</div>
</div>
<!-- Denied message -->
<div v-else-if="userStore.state === 'denied'">
<div v-else-if="userStore.state === MemberState.Denied">
<div class="w-full max-w-2xl flex flex-col gap-8">
<h1 class="text-3xl sm:text-4xl font-bold text-left">
Application Not Approved
@@ -263,7 +267,8 @@ async function restartApp() {
<Button class="w-min" @click="restartApp">New Application</Button>
</div>
</div>
<div v-else-if="userStore.state === 'retired'">
<div
v-else-if="userStore.state === MemberState.Discharged || userStore.state === MemberState.Retired">
<div class="w-full max-w-2xl flex flex-col gap-8">
<h1 class="text-3xl sm:text-4xl font-bold text-left">
You have retired from the 17th Ranger Battalion

View File

@@ -1,98 +1,363 @@
<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 { Member, getMembers } from "@/api/member";
import { useRouter } from 'vue-router';
import { Ellipsis } from "lucide-vue-next";
import Input from "@/components/ui/input/Input.vue";
import LoaForm from "@/components/loa/loaForm.vue";
import { ref, computed, onMounted, watch } from "vue";
import { useRouter } from 'vue-router';
import {
Ellipsis, Search, Trash2, UserX,
X,
} from "lucide-vue-next";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
const members = ref<Member[]>([]);
const router = useRouter();
// API & Types
import { getMembersFiltered, suspendMember, unsuspendMember } 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";
const fetchMembers = async () => {
members.value = await getMembers();
};
// 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 Spinner from "@/components/ui/spinner/Spinner.vue";
import DischargeMember from "@/components/members/DischargeMember.vue";
import MemberCard from "@/components/members/MemberCard.vue";
import { useMemberDirectory } from "@/stores/memberDirectory";
import { Discharge } from "@shared/schemas/dischargeSchema";
import TransferMember from "@/components/members/TransferMember.vue";
function viewMember(id) {
router.push(`/member/${id}`)
}
// --- State ---
const router = useRouter();
const members = ref<Member[]>([]);
const units = ref<Unit[]>([]);
const isLoaded = ref(false);
const pagination = ref<PaginationType>({
page: 1,
pageSize: 15,
total: 0,
totalPages: 0,
});
fetchMembers();
const filters = ref<{ search: string; status: "all" | MemberState; unitId: string }>({
search: "",
status: MemberState.Member,
unitId: "all"
});
const searchVal = ref<string>("");
const searchedMembers = computed(() => {
return members.value.filter(member => member.member_name.toLowerCase().includes(searchVal.value.toLowerCase()));
});
// Pagination State
const pageNum = ref(1);
const pageSize = ref(15);
const pageSizeOptions = [10, 15, 30];
const MEMBER_STATUSES = Object.entries(MemberState)
.filter(([key, value]) => isNaN(Number(key)))
.map(([label, id]) => ({
label,
id: id as number // Casting back to number for your SQL logic
}));
// --- 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(() => {
setPage(1); //reset page number when filters change to avoid that one annoying bug eagle keeps finding
fetchMembers();
}, 300);
}, { deep: true });
function clearFilters() {
filters.value = {
search: "",
status: "all",
unitId: "all"
}
}
onMounted(() => {
fetchUnits();
fetchMembers();
});
//discharge form logic
const isDischargeOpen = ref(false)
const isTransferOpen = ref(false)
const targetMember = ref<Member | null>(null)
function openDischargeModal(member: Member) {
targetMember.value = member
isDischargeOpen.value = true
}
function openTransferModal(member: Member) {
targetMember.value = member
isTransferOpen.value = true
}
async function onSuspend(member: Member) {
await suspendMember(member.member_id);
await fetchMembers();
memberCache.invalidateMember(member.member_id);
}
async function onUnsuspend(member: Member) {
await unsuspendMember(member.member_id);
await fetchMembers();
memberCache.invalidateMember(member.member_id);
}
const memberCache = useMemberDirectory();
function handleDischargeSuccess(value: { data: Discharge }) {
fetchMembers();
memberCache.invalidateMember(value.data.userID);
}
function handleTransferSuccess(value: { memberId: number; unitId: number; rankId: number; reason: string }) {
fetchMembers();
memberCache.invalidateMember(value.memberId);
}
</script>
<template>
<!-- table menu -->
<div class="w-4xl mx-auto">
<div class="flex justify-between mb-4">
<Input v-model="searchVal" placeholder="search..."></Input>
<div>
<DischargeMember v-model:open="isDischargeOpen" :member="targetMember" @discharged="handleDischargeSuccess">
</DischargeMember>
<TransferMember v-model:open="isTransferOpen" :member="targetMember" @transferred="handleTransferSuccess">
</TransferMember>
<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>
</div>
<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 capitalize">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem v-for="s in MEMBER_STATUSES" :key="s.id" :value="s.id">
<span class="capitalize">{{ s.label }}</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 !== MemberState.Member || filters.unitId !== 'all'" variant="ghost"
size="sm" class="h-8 px-2 text-xs text-muted-foreground"
@click="filters.status = MemberState.Member; 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 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>State</TableHead>
<TableHead></TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<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">
<MemberCard :member-id="member.member_id"></MemberCard>
</TableCell>
<!-- <TableCell class="font-medium py-4">{{ member.displayName || member.member_name }}</TableCell> -->
<TableCell>{{ member.rank }}</TableCell>
<TableCell>{{ member.unit }}</TableCell>
<TableCell>
<Badge variant="outline" class="capitalize font-normal">{{
MemberState[member.member_state] }}</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 v-if="member.member_state === MemberState.Member"
@click="openTransferModal(member)">
Transfer Member
</DropdownMenuItem>
<DropdownMenuItem v-if="member.member_state !== MemberState.Discharged"
@click="openDischargeModal(member)"
class="text-destructive focus:bg-destructive focus:text-destructive-foreground font-medium">
Discharge Member
</DropdownMenuItem>
<DropdownMenuItem v-if="member.member_state !== MemberState.Suspended"
@click="onSuspend(member)"
class="text-destructive focus:bg-destructive focus:text-destructive-foreground font-medium">
Suspend Member
</DropdownMenuItem>
<DropdownMenuItem v-else @click="onUnsuspend(member)"
class="text-destructive focus:bg-destructive focus:text-destructive-foreground font-medium">
Remove Suspension
</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>
<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" :show-edges="true" @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>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">
Member
</TableHead>
<TableHead>Rank</TableHead>
<TableHead>Unit</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="member in searchedMembers" :key="member.member_id"
:onClick="() => { viewMember(member.member_id) }" class="cursor-pointer">
<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.on_loa">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'">Retire</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</template>