Did more stuff than I even wanna write. Notably:

- Auth/account management
- Navigation system
- Admin views for LOA stuff
This commit is contained in:
2025-09-18 20:33:19 -04:00
parent 4fcd485e75
commit f708349a99
20 changed files with 2139 additions and 85 deletions

View File

@@ -2,24 +2,99 @@
import { RouterLink, RouterView } from 'vue-router';
import Separator from './components/ui/separator/Separator.vue';
import Button from './components/ui/button/Button.vue';
import Application from './pages/Application.vue';
import AutoForm from './components/form/AutoForm.vue';
import ManageApplications from './pages/ManageApplications.vue';
import { Popover, PopoverContent, PopoverTrigger } from './components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './components/ui/dropdown-menu';
import { onMounted } from 'vue';
import { useUserStore } from './stores/user';
const userStore = useUserStore();
onMounted(async () => {
const res = await fetch(`${import.meta.env.VITE_APIHOST}/members/me`, {
credentials: 'include',
});
const data = await res.json();
userStore.user = data;
});
</script>
<template>
<div>
<div class="h-15 flex items-center justify-center gap-20">
<Button variant="link">Link</Button>
<Button variant="link">Link</Button>
<Button variant="link">Link</Button>
<Button variant="link">Link</Button>
<div class="flex items-center justify-between px-10">
<div></div>
<div class="h-15 flex items-center justify-center gap-20">
<RouterLink to="/">
<Button variant="link">Home</Button>
</RouterLink>
<!-- <RouterLink to="/">
<Button variant="link">Calendar</Button>
</RouterLink> -->
<RouterLink to="/members">
<Button variant="link">Members</Button>
</RouterLink>
<Popover>
<PopoverTrigger as-child>
<Button variant="link">Forms</Button>
</PopoverTrigger>
<PopoverContent class="flex flex-col gap-4 items-center w-min">
<RouterLink to="/transfer">
<Button variant="link">Transfer Request</Button>
</RouterLink>
<RouterLink to="/trainingReport">
<Button variant="link">Training Report</Button>
</RouterLink>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger as-child>
<Button variant="link">Administration</Button>
</PopoverTrigger>
<PopoverContent class="flex flex-col gap-4 items-center w-min">
<RouterLink to="/administration/rankChange">
<Button variant="link">Promotions</Button>
</RouterLink>
<RouterLink to="/administration/loa">
<Button variant="link">Leave of Absence</Button>
</RouterLink>
<RouterLink to="/administration/transfer">
<Button variant="link">Transfer Requests</Button>
</RouterLink>
<RouterLink to="/administration/applications">
<Button variant="link">Recruitment</Button>
</RouterLink>
</PopoverContent>
</Popover>
</div>
<div>
<DropdownMenu v-if="userStore.isLoggedIn">
<DropdownMenuTrigger class="cursor-pointer">
<p>Profile</p>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>My Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>
<RouterLink to="/loa">
Submit LOA
</RouterLink>
</DropdownMenuItem>
<DropdownMenuItem :variant="'destructive'">Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<a v-else href="https://aj17thdevapi.nexuszone.net/login">Login</a>
</div>
</div>
<Separator></Separator>
<!-- <Application></Application> -->
<!-- <ManageApplications></ManageApplications> -->
<!-- <AutoForm class="max-w-3xl mx-auto my-20"></AutoForm> -->
<RouterView></RouterView>
<RouterView></RouterView>
</div>
</template>

View File

@@ -1,4 +1,6 @@
export type LOARequest = {
id: number;
name?: string;
member_id: number;
filed_date: string; // ISO 8601 string
start_date: string; // ISO 8601 string
@@ -43,3 +45,18 @@ export async function getMyLOA(): Promise<LOARequest | null> {
return null;
}
}
export function getAllLOAs(): Promise<LOARequest[]> {
return fetch(`${addr}/loa/all`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
}).then((res) => {
if (res.ok) {
return res.json();
} else {
return [];
}
});
}

34
ui/src/api/status.ts Normal file
View File

@@ -0,0 +1,34 @@
export type Status = {
id: number;
name: string;
created_at: string; // datetime as ISO string
updated_at: string; // datetime as ISO string
deleted?: boolean; // tinyint, optional if nullable
};
// @ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getAllStatuses(): Promise<Status[]> {
const res = await fetch(`${addr}/status`)
if (res.ok) {
return res.json()
} else {
console.error("Something went wrong getting statuses")
}
}
export async function assignStatus(userId: number, statusId: number, rankId: number): Promise<void> {
const res = await fetch(`${addr}/memberStatus`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ userId, statusId, rankId })
})
if (!res.ok) {
console.error("Something went wrong assigning the status")
}
}

View File

@@ -91,12 +91,12 @@ async function handleSubmit() {
}
function toMariaDBDatetime(date: Date): string {
return date.toISOString().slice(0, 19).replace('T', ' ');
return date.toISOString().slice(0, 19).replace('T', ' ');
}
</script>
<template>
<div class="flex flex-row-reverse gap-6 mx-auto m-10" :class="!adminMode ? 'max-w-5xl' : 'max-w-2xl'">
<div class="flex flex-row-reverse gap-6 mx-auto " :class="!adminMode ? 'max-w-5xl' : 'max-w-2xl'">
<div v-if="!adminMode" class="flex-1 flex space-x-4 rounded-md border p-4">
<div class="flex-2 space-y-1">
<p class="text-sm font-medium leading-none">
@@ -156,11 +156,7 @@ function toMariaDBDatetime(date: Date): string {
</PopoverContent>
</Popover>
</div>
<Textarea
v-model="reason"
placeholder="Reason for LOA"
class="w-full resize-none"
/>
<Textarea v-model="reason" placeholder="Reason for LOA" class="w-full resize-none" />
<div class="flex justify-end">
<Button :onClick="handleSubmit" :disabled="submitting" class="w-min">Submit</Button>
</div>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { getAllLOAs, LOARequest } from "@/api/loa";
import { onMounted, ref } from "vue";
const LOAList = ref<LOARequest[]>([]);
onMounted(async () => {
LOAList.value = await getAllLOAs();
});
function formatDate(dateStr: string): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
</script>
<template>
<div class="w-5xl mx-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Member</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Posted on</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="post in LOAList"
:key="post.id"
class="hover:bg-muted/50"
>
<TableCell class="font-medium">
{{ post.name }}
</TableCell>
<TableCell>{{ formatDate(post.start_date) }}</TableCell>
<TableCell>{{ formatDate(post.end_date) }}</TableCell>
<TableCell>{{ post.reason }}</TableCell>
<TableCell>{{ formatDate(post.filed_date) }}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
import { badgeVariants } from ".";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
variant: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,24 @@
import { cva } from "class-variance-authority";
export { default as Badge } from "./Badge.vue";
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import LoaForm from '@/components/loa/loaForm.vue';
import LoaList from '@/components/loa/loaList.vue';
</script>
<template>
<div class="max-w-5xl mx-auto pt-10">
<!-- <LoaForm class="m-10"></LoaForm> -->
<h1>LOA Log</h1>
<LoaList></LoaList>
</div>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LoaForm class="m-10"></LoaForm>
</template>

95
ui/src/pages/Transfer.vue Normal file
View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { Check, Search } from "lucide-vue-next"
import { Combobox, ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox"
import { onMounted, ref } from "vue";
import { Member, getMembers } from "@/api/member";
import Button from "@/components/ui/button/Button.vue";
import { Status, getAllStatuses, assignStatus } from "@/api/status";
import { Rank, getRanks } from "@/api/rank";
const members = ref<Member[]>([])
const statuses = ref<Status[]>([])
const allRanks = ref<Rank[]>([])
const currentMember = ref<Member | null>(null);
const currentStatus = ref<Status | null>(null);
const currentRank = ref<Rank | null>(null);
onMounted(async () => {
members.value = await getMembers();
statuses.value = await getAllStatuses();
allRanks.value = await getRanks();
});
</script>
<template>
<div class="flex flex-row gap-6 mx-auto m-10 max-w-5xl">
<Combobox v-model="currentMember">
<ComboboxAnchor class="w-[300px]">
<ComboboxInput placeholder="Search members..." class="w-full pl-9"
:display-value="(v) => v ? v.member_name : ''" />
</ComboboxAnchor>
<ComboboxList class="w-[300px]">
<ComboboxEmpty class="text-muted-foreground">No results</ComboboxEmpty>
<ComboboxGroup>
<template v-for="member in members" :key="member.member_id">
<ComboboxItem :value="member"
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5">
{{ member.member_name }}
<ComboboxItemIndicator class="absolute left-2 inline-flex items-center">
<Check class="h-4 w-4" />
</ComboboxItemIndicator>
</ComboboxItem>
</template>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<!-- Status Combobox -->
<Combobox v-model="currentStatus">
<ComboboxAnchor class="w-[300px]">
<ComboboxInput placeholder="Search statuses..." class="w-full pl-9"
:display-value="(v) => v ? v.name : ''" />
</ComboboxAnchor>
<ComboboxList class="w-[300px]">
<ComboboxEmpty class="text-muted-foreground">No results</ComboboxEmpty>
<ComboboxGroup>
<template v-for="status in statuses" :key="status.id">
<ComboboxItem :value="status"
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5">
{{ status.name }}
<ComboboxItemIndicator class="absolute left-2 inline-flex items-center">
<Check class="h-4 w-4" />
</ComboboxItemIndicator>
</ComboboxItem>
</template>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<!-- rank -->
<Combobox v-model="currentRank">
<ComboboxAnchor class="w-[300px]">
<ComboboxInput placeholder="Search ranks..." class="w-full pl-9"
:display-value="(v) => v ? v.short_name : ''" />
</ComboboxAnchor>
<ComboboxList class="w-[300px]">
<ComboboxEmpty class="text-muted-foreground">No results</ComboboxEmpty>
<ComboboxGroup>
<template v-for="rank in allRanks" :key="rank.id">
<ComboboxItem :value="rank"
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5">
{{ rank.short_name }}
<ComboboxItemIndicator class="absolute left-2 inline-flex items-center">
<Check class="h-4 w-4" />
</ComboboxItemIndicator>
</ComboboxItem>
</template>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<Button :onClick="() => { }">Submit</Button>
</div>
</template>

View File

@@ -11,22 +11,24 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
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";
const members = ref<Member[]>([]);
const router = useRouter();
@@ -46,9 +48,24 @@ const searchedMembers = computed(() => {
return members.value.filter(member => member.member_name.toLowerCase().includes(searchVal.value.toLowerCase()));
});
// page state systems
const showLOADialog = ref(false);
const LOAuserId = ref<number | null>(null);
</script>
<template>
<Dialog v-model:open="showLOADialog" v-on:update:open="showLOADialog = false">
<DialogContent>
<DialogHeader>
<DialogTitle>LOA Menu</DialogTitle>
<DialogDescription>
Something something flavor text.
</DialogDescription>
</DialogHeader>
<LoaForm :adminMode="true"></LoaForm>
</DialogContent>
</Dialog>
<!-- table menu -->
<div class="w-4xl mx-auto">
<div class="flex justify-between mb-4">
@@ -72,6 +89,8 @@ const searchedMembers = computed(() => {
</TableCell>
<TableCell>{{ member.rank }}</TableCell>
<TableCell>{{ member.status }}</TableCell>
<TableCell>{{ member.status }}</TableCell>
<TableCell>{{ member.status }}</TableCell>
<TableCell @click.stop="console.log('hi')" class="text-right">
<DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer">
@@ -80,7 +99,7 @@ const searchedMembers = computed(() => {
<DropdownMenuContent>
<DropdownMenuItem>Change Rank</DropdownMenuItem>
<DropdownMenuItem>Transfer</DropdownMenuItem>
<DropdownMenuItem>LOA</DropdownMenuItem>
<DropdownMenuItem @click="showLOADialog = true">LOA</DropdownMenuItem>
<DropdownMenuItem :variant="'destructive'">Retire</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,18 +1,35 @@
import { createRouter, createWebHistory } from 'vue-router'
import ManageApplications from '@/pages/ManageApplications.vue'
import Application from '@/pages/Application.vue'
import RankChange from '@/pages/RankChange.vue'
import MemberList from '@/pages/memberList.vue'
import LOA from '@/pages/LOA.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/applications', component: ManageApplications },
{ path: '/applications/:id', component: Application },
{ path: '/changeRank', component: RankChange },
{ path: '/members', component: MemberList},
{ path: '/loa', component: LOA}
{ path: '/applications', component: () => import('@/pages/ManageApplications.vue') },
{ path: '/applications/:id', component: () => import('@/pages/Application.vue') },
{ path: '/rankChange', component: () => import('@/pages/RankChange.vue') },
{ path: '/members', component: () => import('@/pages/memberList.vue') },
{ path: '/loa', component: () => import('@/pages/SubmitLOA.vue') },
{ path: '/transfer', component: () => import('@/pages/Transfer.vue') },
{
path: '/administration',
children: [
{
path: 'applications',
component: () => import('@/pages/ManageApplications.vue')
},
{
path: 'applications/:id',
component: () => import('@/pages/Application.vue')
},
{
path: 'transfer-requests',
component: () => import('@/pages/RankChange.vue')
},
{
path: 'loa',
component: () => import('@/pages/ManageLOA.vue')
}
]
}
]
})

10
ui/src/stores/user.ts Normal file
View File

@@ -0,0 +1,10 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
const user = ref(null)
const roles = ref<string[]>([])
const isLoggedIn = computed(() => user.value !== null)
return { user, isLoggedIn, roles }
})