Files
milsim-site-v4/ui/src/pages/memberList.vue

311 lines
14 KiB
Vue

<script setup lang="ts">
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'
// 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 Spinner from "@/components/ui/spinner/Spinner.vue";
import DischargeMember from "@/components/members/DischargeMember.vue";
import MemberCard from "@/components/members/MemberCard.vue";
// --- 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,
});
const filters = ref<{ search: string; status: "all" | MemberState; unitId: string }>({
search: "",
status: MemberState.Member,
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();
});
//discharge form logic
const isDischargeOpen = ref(false)
const targetMember = ref(null)
function openDischargeModal(member) {
targetMember.value = member
isDischargeOpen.value = true
}
function handleDischargeSuccess(data) {
fetchMembers();
}
</script>
<template>
<div>
<DischargeMember v-model:open="isDischargeOpen" :member="targetMember" @discharged="handleDischargeSuccess">
</DischargeMember>
<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" :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 !== 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>Status</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">{{ 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 @click="openDischargeModal(member)"
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>
<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>
</div>
</template>