Compare commits
16 Commits
1.2.0
...
Mobile-Enh
| Author | SHA1 | Date | |
|---|---|---|---|
| 676e09aef5 | |||
| b40e4f3ec7 | |||
| b4ff7d4686 | |||
| 171e3b3137 | |||
| dd2ac19e4a | |||
| 18ba9b576c | |||
| 6c85677e1b | |||
| adb5e3e137 | |||
| 1749c3e617 | |||
| 52ee36be44 | |||
| 0cc327a9c4 | |||
| ef3cbbf370 | |||
| 209d0cdf0f | |||
| 0e37a8bc9c | |||
| 045de2fe98 | |||
| a1ca07e6fd |
@@ -396,11 +396,11 @@ VALUES(?, ?, ?, 1);`
|
||||
INNER JOIN members AS member ON member.id = app.poster_id
|
||||
WHERE app.id = ?; `;
|
||||
const comment = await conn.query(getSQL, [result.insertId])
|
||||
audit.record('application', 'comment_added', { actorId: user.id, targetId: appID }, { commentId: result.insertId });
|
||||
audit.record('application', 'comment_added', { actorId: user.id, targetId: appID }, { commentId: Number(result.insertId) });
|
||||
logger.info('app', "Admin application comment posted", {
|
||||
application: appID,
|
||||
poster: user.id,
|
||||
comment: result.insertId,
|
||||
comment: Number(result.insertId),
|
||||
})
|
||||
|
||||
res.status(201).json(comment[0]);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { requireLogin, requireMemberState, requireRole } from '../middleware/aut
|
||||
import { getUserActiveLOA } from '../services/db/loaService';
|
||||
import { getAllMembersLite, getMemberSettings, getMembersFull, getMembersLite, getUserData, getUserState, setUserSettings, getFilteredMembers, setUserState, getLastNonSuspendedState } from '../services/db/memberService';
|
||||
import { getUserRoles, stripUserRoles } from '../services/db/rolesService';
|
||||
import { memberSettings, MemberState, myData } from '@app/shared/types/member';
|
||||
import { memberSettings, MemberState, myData, UserCacheBustResult } from '@app/shared/types/member';
|
||||
import { Discharge } from '@app/shared/schemas/dischargeSchema';
|
||||
|
||||
import { Performance } from 'perf_hooks';
|
||||
@@ -211,6 +211,32 @@ router.post('/full/bulk', async (req: Request, res: Response) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/cache/user/bust', [requireLogin, requireMemberState(MemberState.Member), requireRole('dev')], async (req: Request, res: Response) => {
|
||||
try {
|
||||
const clearedEntries = memberCache.Clear();
|
||||
const payload: UserCacheBustResult = {
|
||||
success: true,
|
||||
clearedEntries,
|
||||
bustedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
logger.info('app', 'User cache manually busted', {
|
||||
actor: req.user.id,
|
||||
clearedEntries,
|
||||
});
|
||||
|
||||
return res.status(200).json(payload);
|
||||
} catch (error) {
|
||||
logger.error('app', 'Failed to bust user cache', {
|
||||
caller: req.user?.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
return res.status(500).json({ error: 'Failed to bust user cache' });
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id', [requireLogin], async (req, res) => {
|
||||
const userId = req.params.id;
|
||||
|
||||
|
||||
10
api/src/services/cache/cache.ts
vendored
10
api/src/services/cache/cache.ts
vendored
@@ -16,4 +16,14 @@ export class CacheService<Key, Value> {
|
||||
public Invalidate(key: Key): boolean {
|
||||
return this.cacheMap.delete(key);
|
||||
}
|
||||
|
||||
public Size(): number {
|
||||
return this.cacheMap.size;
|
||||
}
|
||||
|
||||
public Clear(): number {
|
||||
const priorSize = this.cacheMap.size;
|
||||
this.cacheMap.clear();
|
||||
return priorSize;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ export async function getFilteredMembers(
|
||||
}
|
||||
|
||||
if (search) {
|
||||
whereClauses.push(`v.member_name LIKE ?`);
|
||||
whereClauses.push(`(v.member_name LIKE ? OR v.displayName LIKE ?)`);
|
||||
params.push(`%${search}%`);
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,3 +51,9 @@ export interface myData {
|
||||
roles: Role[];
|
||||
state: MemberState;
|
||||
}
|
||||
|
||||
export interface UserCacheBustResult {
|
||||
success: boolean;
|
||||
clearedEntries: number;
|
||||
bustedAt: string;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Discharge } from "@shared/schemas/dischargeSchema";
|
||||
import { memberSettings, Member, MemberLight, MemberCardDetails, PaginatedMembers, MemberState } from "@shared/types/member";
|
||||
import { memberSettings, Member, MemberLight, MemberCardDetails, PaginatedMembers, MemberState, UserCacheBustResult } from "@shared/types/member";
|
||||
|
||||
// @ts-ignore
|
||||
const addr = import.meta.env.VITE_APIHOST;
|
||||
@@ -158,3 +158,16 @@ export async function unsuspendMember(memberID: number): Promise<boolean> {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function bustUserCache(): Promise<UserCacheBustResult> {
|
||||
const response = await fetch(`${addr}/members/cache/user/bust`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to bust user cache');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
import Separator from '../ui/separator/Separator.vue';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import {
|
||||
@@ -18,10 +18,11 @@ import NavigationMenuTrigger from '../ui/navigation-menu/NavigationMenuTrigger.v
|
||||
import NavigationMenuContent from '../ui/navigation-menu/NavigationMenuContent.vue';
|
||||
import { navigationMenuTriggerStyle } from '../ui/navigation-menu/'
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
import { ArrowUpRight, CircleArrowOutUpRight } from 'lucide-vue-next';
|
||||
import { ArrowUpRight, ChevronDown, ChevronUp, CircleArrowOutUpRight, LogIn, LogOut, Menu, Settings, X } from 'lucide-vue-next';
|
||||
import DropdownMenuGroup from '../ui/dropdown-menu/DropdownMenuGroup.vue';
|
||||
import DropdownMenuSeparator from '../ui/dropdown-menu/DropdownMenuSeparator.vue';
|
||||
import { MemberState } from '@shared/types/member';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const auth = useAuth();
|
||||
@@ -41,18 +42,152 @@ function blurAfter() {
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
});
|
||||
}
|
||||
|
||||
type NavItem = {
|
||||
title: string;
|
||||
to?: string;
|
||||
href?: string;
|
||||
status?: 'member' | 'guest';
|
||||
isExternal?: boolean;
|
||||
roles?: string[];
|
||||
items?: NavItem[];
|
||||
};
|
||||
|
||||
const navConfig: NavItem[] = [
|
||||
{
|
||||
title: 'Calendar',
|
||||
to: '/calendar',
|
||||
},
|
||||
{
|
||||
title: 'Documents',
|
||||
href: 'https://docs.iceberg-gaming.com',
|
||||
status: 'member',
|
||||
isExternal: true
|
||||
},
|
||||
{
|
||||
title: 'Forms',
|
||||
status: 'member',
|
||||
items: [
|
||||
{ title: 'Leave of Absence', to: '/loa' },
|
||||
{ title: 'Training Report', to: '/trainingReport' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Administration',
|
||||
status: 'member',
|
||||
roles: ['17th Administrator', '17th HQ', '17th Command', 'Recruiter'],
|
||||
items: [
|
||||
{
|
||||
title: 'Leave of Absence',
|
||||
to: '/administration/loa',
|
||||
roles: ['17th Administrator', '17th HQ', '17th Command']
|
||||
},
|
||||
{
|
||||
title: 'Promotions',
|
||||
to: '/administration/rankChange',
|
||||
roles: ['17th Administrator', '17th HQ', '17th Command']
|
||||
},
|
||||
{
|
||||
title: 'Recruitment',
|
||||
to: '/administration/applications',
|
||||
roles: ['Recruiter']
|
||||
},
|
||||
{
|
||||
title: 'Member Management',
|
||||
to: '/administration/members',
|
||||
},
|
||||
{
|
||||
title: 'Role Management',
|
||||
to: '/administration/roles',
|
||||
roles: ['17th Administrator']
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Developer',
|
||||
to: '/developer',
|
||||
roles: ['Dev']
|
||||
},
|
||||
{
|
||||
title: 'Join',
|
||||
to: '/join',
|
||||
status: 'guest',
|
||||
},
|
||||
];
|
||||
|
||||
const filteredNav = computed(() => {
|
||||
return navConfig.flatMap(item => {
|
||||
const filtered: NavItem[] = [];
|
||||
|
||||
// 1. Check Login Requirements
|
||||
const isLoggedIn = userStore.isLoggedIn;
|
||||
|
||||
// 2. Determine visibility based on status
|
||||
let shouldShow = false;
|
||||
|
||||
if (!item.status) {
|
||||
// Public items - always show
|
||||
shouldShow = true;
|
||||
} else if (item.status === 'guest') {
|
||||
// Show if NOT logged in OR logged in as guest (but NOT a member)
|
||||
shouldShow = !isLoggedIn || auth.accountStatus.value === MemberState.Guest;
|
||||
} else if (item.status === 'member') {
|
||||
// Show ONLY if logged in as member
|
||||
shouldShow = isLoggedIn && auth.accountStatus.value === MemberState.Member;
|
||||
}
|
||||
|
||||
// 3. Check Role Requirements (if status check passed)
|
||||
if (shouldShow && item.roles) {
|
||||
shouldShow = auth.hasAnyRole(item.roles);
|
||||
}
|
||||
|
||||
if (shouldShow) {
|
||||
if (item.items) {
|
||||
const filteredItems = item.items.filter(subItem =>
|
||||
!subItem.roles || auth.hasAnyRole(subItem.roles)
|
||||
);
|
||||
filtered.push({ ...item, items: filteredItems });
|
||||
} else {
|
||||
filtered.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
})
|
||||
|
||||
const isMobileMenuOpen = ref(false);
|
||||
const expandedMenu = ref(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function openMobileMenu() {
|
||||
expandedMenu.value = null;
|
||||
isMobileMenuOpen.value = true;
|
||||
}
|
||||
|
||||
function closeMobileMenu() {
|
||||
isMobileMenuOpen.value = false;
|
||||
expandedMenu.value = null;
|
||||
}
|
||||
|
||||
function mobileNavigateTo(to: string) {
|
||||
closeMobileMenu();
|
||||
router.push(to);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full border-b">
|
||||
<div class="max-w-screen-3xl w-full mx-auto flex items-center justify-between pr-10 pl-7">
|
||||
<div class="hidden lg:flex max-w-screen-3xl w-full mx-auto items-center justify-between pr-10 pl-7">
|
||||
<!-- left side -->
|
||||
<div class="flex items-center gap-7">
|
||||
<RouterLink to="/">
|
||||
<img src="/17RBN_Logo.png" class="w-10 h-10 object-contain"></img>
|
||||
</RouterLink>
|
||||
<!-- Member navigation -->
|
||||
<div v-if="auth.accountStatus.value == MemberState.Member" class="h-15 flex items-center justify-center">
|
||||
<div v-if="auth.accountStatus.value == MemberState.Member"
|
||||
class="h-15 flex items-center justify-center">
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList class="gap-3">
|
||||
|
||||
@@ -153,6 +288,12 @@ function blurAfter() {
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem v-if="auth.hasRole('Dev')">
|
||||
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
|
||||
<RouterLink to="/developer" @click="blurAfter">Developer</RouterLink>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
@@ -196,6 +337,109 @@ function blurAfter() {
|
||||
<a v-else :href="APIHOST + '/login'">Login</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <Separator></Separator> -->
|
||||
|
||||
<!-- mobile navigation -->
|
||||
<div class="flex flex-col lg:hidden w-full" :class="isMobileMenuOpen ? 'h-screen' : ''">
|
||||
<div class="flex items-center justify-between w-full p-2">
|
||||
<!-- <RouterLink to="/">
|
||||
<img src="/17RBN_Logo.png" class="w-10 h-10 object-contain"></img>
|
||||
</RouterLink> -->
|
||||
<button @click="mobileNavigateTo('/')">
|
||||
<img src="/17RBN_Logo.png" class="w-10 h-10 object-contain"></img>
|
||||
</button>
|
||||
|
||||
<Button v-if="!isMobileMenuOpen" variant="ghost" size="icon" @click="openMobileMenu()">
|
||||
<Menu class="size-7" />
|
||||
</Button>
|
||||
<Button v-else variant="ghost" size="icon" @click="closeMobileMenu()">
|
||||
<X class="size-7" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="isMobileMenuOpen" class="flex flex-col h-[calc(100vh-60px)] overflow-hidden">
|
||||
<div class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
|
||||
<div v-for="navItem in filteredNav" :key="navItem.title" class="group">
|
||||
|
||||
<template v-if="!navItem.items">
|
||||
<a v-if="navItem.isExternal" :href="navItem.href" target="_blank"
|
||||
class="flex items-center justify-between w-full px-4 py-2.5 text-base font-medium rounded-md hover:bg-accent active:bg-accent transition-colors">
|
||||
<span class="flex items-center gap-2">
|
||||
{{ navItem.title }}
|
||||
<ArrowUpRight class="h-3.5 w-3.5 opacity-50" />
|
||||
</span>
|
||||
</a>
|
||||
<button v-else @click="mobileNavigateTo(navItem.to)"
|
||||
class="w-full text-left px-4 py-2.5 text-base font-medium rounded-md hover:bg-accent active:bg-accent transition-colors">
|
||||
{{ navItem.title }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-else class="space-y-0.5">
|
||||
<button @click="expandedMenu = expandedMenu === navItem.title ? null : navItem.title"
|
||||
class="flex items-center justify-between w-full px-4 py-2.5 text-base font-medium rounded-md transition-colors"
|
||||
:class="expandedMenu === navItem.title ? 'bg-accent/50 text-primary' : 'hover:bg-accent'">
|
||||
{{ navItem.title }}
|
||||
<ChevronDown class="h-4 w-4 transition-transform duration-200"
|
||||
:class="expandedMenu === navItem.title ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
|
||||
<div v-if="expandedMenu === navItem.title"
|
||||
class="ml-4 mr-2 border-l border-border space-y-0.5">
|
||||
<button v-for="subNavItem in navItem.items" :key="subNavItem.title"
|
||||
@click="mobileNavigateTo(subNavItem.to)"
|
||||
class="w-full text-left px-6 py-2 text-sm text-muted-foreground hover:text-foreground active:text-primary transition-colors">
|
||||
{{ subNavItem.title }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 border-t bg-background mt-auto">
|
||||
<div v-if="userStore.isLoggedIn" class="space-y-3">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<div class="flex gap-3">
|
||||
<!-- <div
|
||||
class="size-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
|
||||
{{ userStore.displayName?.charAt(0) }}
|
||||
</div> -->
|
||||
<div class="flex flex-col leading-tight">
|
||||
<span class="text-sm font-semibold">
|
||||
{{ userStore.displayName || userStore.user.member.member_name }}
|
||||
</span>
|
||||
<span v-if="userStore.displayName"
|
||||
class="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{{ userStore.user.member.member_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" @click="mobileNavigateTo('/profile')">
|
||||
<Settings class="size-6"></Settings>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" @click="logout()">
|
||||
<LogOut class="size-6 text-destructive"></LogOut>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<!-- <Button variant="outline" size="xs" class="flex-1 h-8 text-xs"
|
||||
@click="mobileNavigateTo('/profile')">Profile</Button> -->
|
||||
<Button variant="secondary" size="xs" class="flex-1 h-8 text-xs"
|
||||
@click="mobileNavigateTo('/join')">My
|
||||
Application</Button>
|
||||
<Button variant="secondary" size="xs" class="flex-1 h-8 text-xs"
|
||||
@click="mobileNavigateTo('/applications')">Application History</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a v-else :href="APIHOST + '/login'" class="block">
|
||||
<Button class="w-full text-sm h-10">
|
||||
<LogIn></LogIn> Login
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -135,11 +135,7 @@ function setAllToday() {
|
||||
<div class="w-xl">
|
||||
<form v-if="!formSubmitted" id="trainingForm" @submit.prevent="submitForm"
|
||||
class="w-full min-w-0 flex flex-col gap-4">
|
||||
<div>
|
||||
<FieldLegend class="scroll-m-20 text-2xl font-semibold tracking-tight">
|
||||
Promotion Form
|
||||
</FieldLegend>
|
||||
</div>
|
||||
|
||||
<VeeFieldArray name="promotions" v-slot="{ fields, push, remove }">
|
||||
<FieldSet class="w-full min-w-0">
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
@@ -227,6 +227,9 @@ onMounted(() => {
|
||||
/* 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);
|
||||
@@ -299,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) ---------- */
|
||||
@@ -346,9 +350,7 @@ onMounted(() => {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:global(#app > div > div.flex-1.min-h-0 > div > div > div > div.fc.fc-media-screen.fc-direction-ltr.fc-theme-standard > div.fc-view-harness.fc-view-harness-passive > div > table > thead > tr > th) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:global(.fc .fc-daygrid-day-top) {
|
||||
padding: 8px 8px 0 8px;
|
||||
}
|
||||
|
||||
52
ui/src/pages/DeveloperTools.vue
Normal file
52
ui/src/pages/DeveloperTools.vue
Normal 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>
|
||||
@@ -1,29 +1,48 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Plus, PlusIcon, X } from 'lucide-vue-next'
|
||||
import PromotionForm from '@/components/promotions/promotionForm.vue'
|
||||
import PromotionList from '@/components/promotions/promotionList.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogTrigger from '@/components/ui/dialog/DialogTrigger.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const isFormOpen = ref(false)
|
||||
const listRef = ref(null)
|
||||
|
||||
const isMobileFormOpen = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto mt-6 lg:mt-10 px-4 lg:px-8">
|
||||
|
||||
<div class="flex items-center justify-between mb-6 lg:hidden">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Promotion History</h1>
|
||||
|
||||
<Dialog v-model:open="isFormOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button size="sm" class="gap-2">
|
||||
<Plus class="size-4" />
|
||||
Promote
|
||||
<div class="flex flex-col items-center justify-between mb-6 lg:hidden">
|
||||
<div v-if="isMobileFormOpen">
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">
|
||||
Promotion Form
|
||||
</p>
|
||||
<Button variant="ghost" size="icon" @click="isMobileFormOpen = false">
|
||||
<X v-if="isMobileFormOpen" class="size-6"></X>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="w-full h-full max-w-none m-0 rounded-none flex flex-col">
|
||||
<DialogHeader class="flex-row items-center justify-between border-b pb-4">
|
||||
<DialogTitle>New Promotion</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex-1 overflow-y-auto pt-6">
|
||||
<PromotionForm @submitted="handleMobileSubmit" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PromotionForm @submitted="listRef?.refresh" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex justify-between w-full">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Promotion History</h1>
|
||||
<Button @click="isMobileFormOpen = true">
|
||||
<PlusIcon />Submit
|
||||
</Button>
|
||||
</div>
|
||||
<PromotionList></PromotionList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row lg:max-h-[70vh] gap-8">
|
||||
<div class="hidden lg:flex flex-row lg:max-h-[70vh] gap-8">
|
||||
<div class="flex-1 lg:border-r lg:pr-8 w-full lg:min-w-2xl">
|
||||
<p class="hidden lg:block scroll-m-20 text-2xl font-semibold tracking-tight mb-3">
|
||||
Promotion History
|
||||
@@ -42,24 +61,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import PromotionForm from '@/components/promotions/promotionForm.vue'
|
||||
import PromotionList from '@/components/promotions/promotionList.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogTrigger from '@/components/ui/dialog/DialogTrigger.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const isFormOpen = ref(false)
|
||||
const listRef = ref(null)
|
||||
|
||||
const handleMobileSubmit = () => {
|
||||
isFormOpen.value = false // Close the "Whole page" modal
|
||||
listRef.value?.refresh() // Refresh the list behind it
|
||||
}
|
||||
</script>
|
||||
@@ -28,10 +28,12 @@ const router = createRouter({
|
||||
{ path: '/trainingReport/new', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
|
||||
{ path: '/trainingReport/:id', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
|
||||
|
||||
{ path: '/developer', component: () => import('@/pages/DeveloperTools.vue'), meta: { requiresAuth: true, memberOnly: true, roles: ['Dev'] } },
|
||||
|
||||
// ADMIN / STAFF ROUTES
|
||||
{
|
||||
path: '/administration',
|
||||
meta: { requiresAuth: true, memberOnly: true, roles: ['17th Administrator', '17th HQ', '17th Command'] },
|
||||
meta: { requiresAuth: true, memberOnly: true, roles: ['17th Administrator', '17th HQ', '17th Command', '17th Recruiter'] },
|
||||
children: [
|
||||
{ path: 'applications', component: () => import('@/pages/ManageApplications.vue') },
|
||||
{ path: 'applications/:id', component: () => import('@/pages/ManageApplications.vue') },
|
||||
|
||||
Reference in New Issue
Block a user