Implemented self extension

This commit is contained in:
2026-02-20 00:10:08 -05:00
parent 90db7de843
commit 86d069651c
3 changed files with 184 additions and 120 deletions

View File

@@ -189,7 +189,50 @@ router.post('/adminCancel/:id', [requireRole(['17th Administrator', '17th HQ', '
}) })
// extend LOA // extend LOA
router.post('/extend/:id', [requireRole(['17th Administrator', '17th HQ', '17th Command'])], async (req: Request, res: Response) => { router.post('/extend/:id', async (req: Request, res: Response) => {
const to: Date = req.body.to;
const member = req.user.id;
let LOA = await getLOAbyID(Number(req.params.id));
if (!LOA) {
return res.status(404).send("LOA not found");
}
if (LOA.member_id !== member) {
return res.status(403).send("You do not have permission to extend this LOA");
}
if (LOA.extended_till !== null) {
return res.status(409).send("You must contact the administration team to extend your LOA again");
}
if (!to) {
return res.status(400).send("Extension length is required");
}
try {
await setLOAExtension(Number(req.params.id), to);
audit.leaveOfAbsence('extended', { actorId: req.user.id, targetId: Number(req.params.id) });
logger.info('app', 'LOA Extended', { extended_by: req.user.id, LOA: req.params.id })
res.sendStatus(200);
} catch (error) {
logger.error(
'app',
'Failed to extend LOA',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error);
}
})
// admin extend LOA
router.post('/extendAdmin/:id', [requireRole(['17th Administrator', '17th HQ', '17th Command'])], async (req: Request, res: Response) => {
const to: Date = req.body.to; const to: Date = req.body.to;
if (!to) { if (!to) {

View File

@@ -169,6 +169,23 @@ export async function extendLOA(id: number, to: Date) {
} }
}); });
if (res.ok) {
return
} else {
throw new Error("Could not extend LOA");
}
}
export async function adminExtendLOA(id: number, to: Date) {
const res = await fetch(`${addr}/loa/extendAdmin/${id}`, {
method: "POST",
credentials: 'include',
body: JSON.stringify({ to }),
headers: {
"Content-Type": "application/json",
}
});
if (res.ok) { if (res.ok) {
return return
} else { } else {

View File

@@ -1,138 +1,141 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
Table, Table,
TableBody, TableBody,
TableCaption, TableCaption,
TableCell, TableCell,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { ChevronDown, ChevronUp, Ellipsis, X } from "lucide-vue-next"; import { ChevronDown, ChevronUp, Ellipsis, X } from "lucide-vue-next";
import { cancelLOA, extendLOA, getAllLOAs, getMyLOAs } from "@/api/loa"; import { adminExtendLOA, cancelLOA, extendLOA, getAllLOAs, getMyLOAs } from "@/api/loa";
import { onMounted, ref, computed } from "vue"; import { onMounted, ref, computed } from "vue";
import { LOARequest } from "@shared/types/loa"; import { LOARequest } from "@shared/types/loa";
import Dialog from "../ui/dialog/Dialog.vue"; import Dialog from "../ui/dialog/Dialog.vue";
import DialogTrigger from "../ui/dialog/DialogTrigger.vue"; import DialogTrigger from "../ui/dialog/DialogTrigger.vue";
import DialogContent from "../ui/dialog/DialogContent.vue"; import DialogContent from "../ui/dialog/DialogContent.vue";
import DialogHeader from "../ui/dialog/DialogHeader.vue"; import DialogHeader from "../ui/dialog/DialogHeader.vue";
import DialogTitle from "../ui/dialog/DialogTitle.vue"; import DialogTitle from "../ui/dialog/DialogTitle.vue";
import DialogDescription from "../ui/dialog/DialogDescription.vue"; import DialogDescription from "../ui/dialog/DialogDescription.vue";
import Button from "../ui/button/Button.vue"; import Button from "../ui/button/Button.vue";
import Calendar from "../ui/calendar/Calendar.vue"; import Calendar from "../ui/calendar/Calendar.vue";
import { import {
CalendarDate, CalendarDate,
getLocalTimeZone, getLocalTimeZone,
} from "@internationalized/date" } from "@internationalized/date"
import { el } from "@fullcalendar/core/internal-common"; import { el } from "@fullcalendar/core/internal-common";
import MemberCard from "../members/MemberCard.vue"; import MemberCard from "../members/MemberCard.vue";
import { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
PaginationEllipsis, PaginationEllipsis,
PaginationItem, PaginationItem,
PaginationNext, PaginationNext,
PaginationPrevious, PaginationPrevious,
} from '@/components/ui/pagination' } from '@/components/ui/pagination'
import { pagination } from "@shared/types/pagination"; import { pagination } from "@shared/types/pagination";
const props = defineProps<{ const props = defineProps<{
adminMode?: boolean adminMode?: boolean
}>() }>()
const LOAList = ref<LOARequest[]>([]); const LOAList = ref<LOARequest[]>([]);
onMounted(async () => { onMounted(async () => {
await loadLOAs(); await loadLOAs();
});
async function loadLOAs() {
if (props.adminMode) {
let result = await getAllLOAs(pageNum.value, pageSize.value);
LOAList.value = result.data;
pageData.value = result.pagination;
} else {
let result = await getMyLOAs(pageNum.value, pageSize.value);
LOAList.value = result.data;
pageData.value = result.pagination;
}
}
function formatDate(date: Date): string {
if (!date) return "";
date = typeof date === 'string' ? new Date(date) : date;
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}); });
}
function loaStatus(loa: LOARequest): "Upcoming" | "Active" | "Extended" | "Overdue" | "Closed" { async function loadLOAs() {
if (loa.closed) return "Closed"; if (props.adminMode) {
let result = await getAllLOAs(pageNum.value, pageSize.value);
LOAList.value = result.data;
pageData.value = result.pagination;
} else {
let result = await getMyLOAs(pageNum.value, pageSize.value);
LOAList.value = result.data;
pageData.value = result.pagination;
}
}
const now = new Date(); function formatDate(date: Date): string {
const start = new Date(loa.start_date); if (!date) return "";
const end = new Date(loa.end_date); date = typeof date === 'string' ? new Date(date) : date;
const extension = new Date(loa.extended_till); return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
if (now < start) return "Upcoming"; function loaStatus(loa: LOARequest): "Upcoming" | "Active" | "Extended" | "Overdue" | "Closed" {
if (now >= start && (now <= end)) return "Active"; if (loa.closed) return "Closed";
if (now >= start && (now <= extension)) return "Extended";
if (now > loa.extended_till || end) return "Overdue";
return "Overdue"; // fallback const now = new Date();
} const start = new Date(loa.start_date);
const end = new Date(loa.end_date);
const extension = new Date(loa.extended_till);
async function cancelAndReload(id: number) { if (now < start) return "Upcoming";
await cancelLOA(id, props.adminMode); if (now >= start && (now <= end)) return "Active";
await loadLOAs(); if (now >= start && (now <= extension)) return "Extended";
} if (now > loa.extended_till || end) return "Overdue";
const isExtending = ref(false); return "Overdue"; // fallback
const targetLOA = ref<LOARequest | null>(null); }
const extendTo = ref<CalendarDate | null>(null);
const targetEnd = computed(() => { return targetLOA.value.extended_till ? targetLOA.value.extended_till : targetLOA.value.end_date }) async function cancelAndReload(id: number) {
await cancelLOA(id, props.adminMode);
await loadLOAs();
}
function toCalendarDate(date: Date): CalendarDate { const isExtending = ref(false);
if (typeof date === 'string') const targetLOA = ref<LOARequest | null>(null);
date = new Date(date); const extendTo = ref<CalendarDate | null>(null);
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
}
async function commitExtend() { const targetEnd = computed(() => { return targetLOA.value.extended_till ? targetLOA.value.extended_till : targetLOA.value.end_date })
await extendLOA(targetLOA.value.id, extendTo.value.toDate(getLocalTimeZone())); function toCalendarDate(date: Date): CalendarDate {
isExtending.value = false; if (typeof date === 'string')
await loadLOAs(); date = new Date(date);
} return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
}
const expanded = ref<number | null>(null); async function commitExtend() {
const hoverID = ref<number | null>(null); if (props.adminMode) {
await adminExtendLOA(targetLOA.value.id, extendTo.value.toDate(getLocalTimeZone()));
} else {
await extendLOA(targetLOA.value.id, extendTo.value.toDate(getLocalTimeZone()));
}
isExtending.value = false;
await loadLOAs();
}
const pageNum = ref<number>(1); const expanded = ref<number | null>(null);
const pageData = ref<pagination>(); const hoverID = ref<number | null>(null);
const pageSize = ref<number>(15) const pageNum = ref<number>(1);
const pageSizeOptions = [10, 15, 30] const pageData = ref<pagination>();
function setPageSize(size: number) { const pageSize = ref<number>(15)
pageSize.value = size const pageSizeOptions = [10, 15, 30]
pageNum.value = 1;
loadLOAs();
}
function setPage(pagenum: number) { function setPageSize(size: number) {
pageNum.value = pagenum; pageSize.value = size
loadLOAs(); pageNum.value = 1;
} loadLOAs();
}
function setPage(pagenum: number) {
pageNum.value = pagenum;
loadLOAs();
}
</script> </script>
<template> <template>
@@ -145,7 +148,7 @@ function setPage(pagenum: number) {
<div class="flex gap-5"> <div class="flex gap-5">
<Calendar v-model="extendTo" class="rounded-md border shadow-sm w-min" layout="month-and-year" <Calendar v-model="extendTo" class="rounded-md border shadow-sm w-min" layout="month-and-year"
:min-value="toCalendarDate(targetEnd)" :min-value="toCalendarDate(targetEnd)"
:max-value="toCalendarDate(targetEnd).add({ years: 1 })" /> :max-value="props.adminMode ? toCalendarDate(targetEnd).add({ years: 1 }) : toCalendarDate(targetEnd).add({ months: 1 })" />
<div class="flex flex-col w-full gap-3 px-2"> <div class="flex flex-col w-full gap-3 px-2">
<p>Quick Options</p> <p>Quick Options</p>
<Button variant="outline" @click="extendTo = toCalendarDate(targetEnd).add({ days: 7 })">1 <Button variant="outline" @click="extendTo = toCalendarDate(targetEnd).add({ days: 7 })">1
@@ -205,7 +208,8 @@ function setPage(pagenum: number) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem v-if="!post.closed && props.adminMode" <DropdownMenuItem v-if="!post.closed"
:disabled="post.extended_till !== null && !props.adminMode"
@click="isExtending = true; targetLOA = post"> @click="isExtending = true; targetLOA = post">
Extend Extend
</DropdownMenuItem> </DropdownMenuItem>
@@ -256,7 +260,7 @@ function setPage(pagenum: number) {
<div class=""> <div class="">
<p class="text-muted-foreground">Extended to</p> <p class="text-muted-foreground">Extended to</p>
<p class="font-medium text-foreground"> <p class="font-medium text-foreground">
{{post.extended_till ? formatDate(post.extended_till) : 'N/A' }} {{ post.extended_till ? formatDate(post.extended_till) : 'N/A' }}
</p> </p>
</div> </div>
</div> </div>