first pass of discharge form

This commit is contained in:
2026-01-27 15:00:29 -05:00
parent 67562f56aa
commit c646254616
2 changed files with 253 additions and 153 deletions

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { Form, Field as VeeField } from 'vee-validate'
import * as z from 'zod'
import { X, AlertTriangle } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Field, FieldLabel, FieldError } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import MemberCard from './MemberCard.vue'
import { Member } from '@shared/types/member'
// 1. Props for control and data
const props = defineProps<{
open: boolean
member: Member | null
}>()
const emit = defineEmits(['update:open', 'discharged'])
// 2. Discharge-specific schema
const formSchema = toTypedSchema(z.object({
reason: z.string().min(1, "Please provide a valid reason for discharge").max(200),
effectiveDate: z.string().min(1, "Date is required"),
}))
function onSubmit(values: any) {
console.log('Discharging member:', props.member?.member_id)
console.log('Discharge Data:', values)
// Notify parent to refresh/close
emit('discharged', { memberId: props.member?.member_id, ...values })
emit('update:open', false)
}
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<div class="flex items-center gap-2 mb-1">
<!-- <AlertTriangle class="size-5" /> -->
<DialogTitle>Discharge Member</DialogTitle>
</div>
<DialogDescription>
You are initiating the discharge process for <MemberCard :member-id="member.member_id"></MemberCard>
<!-- <span class="font-semibold text-foreground">{{ member }}</span> -->
</DialogDescription>
</DialogHeader>
<Form v-slot="{ handleSubmit }" as="" :validation-schema="formSchema">
<form id="dischargeForm" @submit="handleSubmit($event, onSubmit)" class="space-y-4 py-2">
<VeeField v-slot="{ componentField, errors }" name="reason">
<Field :data-invalid="!!errors.length">
<FieldLabel>Reason for Discharge</FieldLabel>
<Textarea placeholder="Retirement, inactivity, etc. "
v-bind="componentField" class="resize-none" />
<FieldError v-if="errors.length" :errors="errors" />
</Field>
</VeeField>
<VeeField v-slot="{ componentField, errors }" name="effectiveDate">
<Field :data-invalid="!!errors.length">
<FieldLabel>Effective Date</FieldLabel>
<Input type="date" v-bind="componentField" />
<FieldError v-if="errors.length" :errors="errors" />
</Field>
</VeeField>
</form>
</Form>
<DialogFooter class="gap-2 sm:gap-0">
<Button variant="ghost" @click="emit('update:open', false)">
Cancel
</Button>
<Button type="submit" form="dischargeForm" variant="destructive">
Discharge
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -30,6 +30,7 @@ import { Button } from "@/components/ui/button";
import Badge from "@/components/ui/badge/Badge.vue"; 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 Spinner from "@/components/ui/spinner/Spinner.vue"; import Spinner from "@/components/ui/spinner/Spinner.vue";
import DischargeMember from "@/components/members/DischargeMember.vue";
// --- State --- // --- State ---
const router = useRouter(); const router = useRouter();
@@ -128,172 +129,180 @@ onMounted(() => {
fetchUnits(); fetchUnits();
fetchMembers(); fetchMembers();
}); });
//discharge form logic
const isDischargeOpen = ref(false)
const targetMember = ref(null)
function openDischargeModal(member) {
targetMember.value = member
isDischargeOpen.value = true
}
function handleDischargeSuccess(data) {
// Refresh your list or show a toast
console.log('Member removed from list:', data.memberId)
}
</script> </script>
<template> <template>
<div class="mx-auto max-w-7xl w-full py-10 px-4"> <div>
<div class="flex flex-col gap-2"> <DischargeMember v-model:open="isDischargeOpen" :member="targetMember" @discharged="handleDischargeSuccess">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4"> </DischargeMember>
<div class="space-y-0.5"> <div class="mx-auto max-w-7xl w-full py-10 px-4">
<h1 class="text-2xl font-bold tracking-tight text-foreground">Member Management</h1> <div class="flex flex-col gap-2">
<p class="text-muted-foreground text-sm">Directory of all personnel and unit assignments.</p> <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>
<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">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center justify-between border-y border-border/40 py-3"> <Select v-model="filters.status">
<div class="flex items-center gap-2"> <SelectTrigger
<Select v-model="filters.status"> class="h-8 w-fit min-w-[110px] border-none bg-muted/50 hover:bg-muted transition-colors text-xs">
<SelectTrigger <SelectValue placeholder="Status" />
class="h-8 w-fit min-w-[110px] border-none bg-muted/50 hover:bg-muted transition-colors text-xs"> </SelectTrigger>
<SelectValue placeholder="Status" /> <SelectContent>
</SelectTrigger> <SelectItem value="all">All Statuses</SelectItem>
<SelectContent> <SelectItem v-for="s in MEMBER_STATUSES" :key="s" :value="s">
<SelectItem value="all">All Statuses</SelectItem> <span class="capitalize">{{ s }}</span>
<SelectItem v-for="s in MEMBER_STATUSES" :key="s" :value="s"> </SelectItem>
<span class="capitalize">{{ s }}</span> </SelectContent>
</SelectItem> </Select>
</SelectContent> <Select v-model="filters.unitId">
</Select> <SelectTrigger
class="h-8 w-fit min-w-[110px] border-none bg-muted/50 hover:bg-muted transition-colors text-xs">
<Select v-model="filters.unitId"> <SelectValue placeholder="Unit" />
<SelectTrigger </SelectTrigger>
class="h-8 w-fit min-w-[110px] border-none bg-muted/50 hover:bg-muted transition-colors text-xs"> <SelectContent>
<SelectValue placeholder="Unit" /> <SelectItem value="all">All Units</SelectItem>
</SelectTrigger> <SelectItem v-for="u in units" :key="u.id" :value="u.name">
<SelectContent> {{ u.name }}
<SelectItem value="all">All Units</SelectItem> </SelectItem>
<SelectItem v-for="u in units" :key="u.id" :value="u.name"> </SelectContent>
{{ u.name }} </Select>
</SelectItem> <div v-if="filters.status !== 'all' || filters.unitId !== 'all'"
</SelectContent> class="h-4 w-[1px] bg-border mx-1" />
</Select> <Button v-if="filters.status !== 'all' || filters.unitId !== 'all'" variant="ghost" size="sm"
class="h-8 px-2 text-xs text-muted-foreground"
<div v-if="filters.status !== 'all' || filters.unitId !== 'all'" @click="filters.status = 'all'; filters.unitId = 'all'">
class="h-4 w-[1px] bg-border mx-1" /> Clear Filters
</Button>
<Button v-if="filters.status !== 'all' || filters.unitId !== 'all'" variant="ghost" size="sm" </div>
class="h-8 px-2 text-xs text-muted-foreground" <div class="flex items-center gap-2">
@click="filters.status = 'all'; filters.unitId = 'all'"> <div class="relative w-full sm:w-[260px]">
Clear Filters <Search
</Button> class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" />
</div> <Input v-model="filters.search" placeholder="Search members..."
class="h-8 pl-9 pr-8 bg-background text-xs focus-visible:ring-1" />
<div class="flex items-center gap-2"> <button v-if="filters.search" @click="filters.search = ''"
<div class="relative w-full sm:w-[260px]"> class="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/60 hover:text-foreground transition-colors">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" /> <X class="size-3.5" />
<Input v-model="filters.search" placeholder="Search members..." </button>
class="h-8 pl-9 pr-8 bg-background text-xs focus-visible:ring-1" /> </div>
<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>
</div> <div class="min-h-[500px]">
<div class="min-h-[500px]"> <Table>
<Table> <TableHeader>
<TableHeader> <TableRow class="hover:bg-transparent border-b">
<TableRow class="hover:bg-transparent border-b"> <TableHead class="w-[200px]">Member</TableHead>
<TableHead class="w-[200px]">Member</TableHead> <TableHead>Rank</TableHead>
<TableHead>Rank</TableHead> <TableHead>Unit</TableHead>
<TableHead>Unit</TableHead> <TableHead>Status</TableHead>
<TableHead>Status</TableHead> <TableHead>Notes</TableHead>
<TableHead>Notes</TableHead> <TableHead class="text-right">Actions</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 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>
</TableHeader>
<TableRow v-if="paginatedMembers.length === 0" class="hover:bg-transparent"> <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 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 @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"> <TableCell colspan="6" class="h-64 text-center">
<div class="flex flex-col items-center justify-center gap-2 text-muted-foreground"> <div class="flex flex-col items-center justify-center gap-4 text-muted-foreground">
<UserX class="size-10 opacity-20 mb-2" /> <Spinner class="size-8" />
<p class="font-medium">No results found</p>
<p class="text-xs">Try adjusting your filters or search query.</p>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
</template> </TableBody>
</Table>
<TableRow v-else> </div>
<TableCell colspan="6" class="h-64 text-center"> <div class="mt-8 flex flex-col sm:flex-row items-center justify-between gap-4 border-t pt-6">
<div class="flex flex-col items-center justify-center gap-4 text-muted-foreground"> <div class="flex items-center gap-3 text-sm">
<Spinner class="size-8" /> <p class="text-muted-foreground text-nowrap">Items per page:</p>
</div> <div class="flex bg-muted/50 p-1 rounded-md">
</TableCell> <button v-for="size in pageSizeOptions" :key="size" @click="setPageSize(size)"
</TableRow> class="px-3 py-1 rounded transition-all text-xs"
</TableBody> :class="pageSize === size ? 'bg-background shadow-sm font-bold' : 'hover:text-foreground opacity-60'">
</Table> {{ size }}
</div> </button>
</div>
<div class="mt-8 flex flex-col sm:flex-row items-center justify-between gap-4 border-t pt-6"> </div>
<div class="flex items-center gap-3 text-sm"> <Pagination v-if="totalItems > 0" :total="totalItems" :items-per-page="pageSize" :page="pageNum"
<p class="text-muted-foreground text-nowrap">Items per page:</p> :sibling-count="1" @update:page="setPage">
<div class="flex bg-muted/50 p-1 rounded-md"> <PaginationContent v-slot="{ items }">
<button v-for="size in pageSizeOptions" :key="size" @click="setPageSize(size)" <PaginationPrevious />
class="px-3 py-1 rounded transition-all text-xs" <template v-for="(item, index) in items" :key="index">
:class="pageSize === size ? 'bg-background shadow-sm font-bold' : 'hover:text-foreground opacity-60'"> <PaginationItem v-if="item.type === 'page'" :value="item.value"
{{ size }} :is-active="item.value === pageNum">
</button> <Button variant="ghost" size="sm" :class="{ 'bg-muted': pageNum === item.value }"
</div> @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>
<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> </div>
</template> </template>