Compare commits
14 Commits
Mobile-Enh
...
1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b8c6590159 | |||
| 52bea200c8 | |||
| 7fff220053 | |||
| afbb771061 | |||
| cdf8f57eb5 | |||
| 3ff28de269 | |||
| f26a334487 | |||
| c14475258d | |||
| dd21d12dd5 | |||
| a4f762e793 | |||
| 5fdb0b45f0 | |||
| f58d0114eb | |||
| f4abc51198 | |||
| 7d5e9c33bf |
@@ -151,6 +151,7 @@ router.get('/callback', (req, res, next) => {
|
||||
|
||||
router.get('/logout', [requireLogin], function (req, res, next) {
|
||||
req.logout(function (err) {
|
||||
|
||||
if (err) { return next(err); }
|
||||
|
||||
req.session.destroy((err) => {
|
||||
@@ -168,10 +169,6 @@ router.get('/logout', [requireLogin], function (req, res, next) {
|
||||
returnTo: process.env.CLIENT_URL
|
||||
};
|
||||
|
||||
logger.info('auth', `Member logged out`, {
|
||||
user: req.user.id,
|
||||
});
|
||||
|
||||
res.redirect(process.env.AUTH_END_SESSION_URI + '?' + querystring.stringify(params));
|
||||
})
|
||||
});
|
||||
|
||||
@@ -29,12 +29,12 @@ const version = import.meta.env.VITE_APPLICATION_VERSION;
|
||||
background-position: center;">
|
||||
<div class="sticky top-0 bg-background z-50">
|
||||
<Navbar class="flex"></Navbar>
|
||||
<Alert v-if="environment == 'dev'" class="m-2 mx-auto w-5xl" variant="info">
|
||||
<AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto">
|
||||
<Alert v-if="environment == 'dev'" class="m-2 mx-auto max-w-5xl" variant="info">
|
||||
<AlertDescription class="flex flex-row items-center text-wrap gap-5 mx-auto">
|
||||
<p>Development environment (v{{ version }}). Features may be incomplete or unavailable.</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Alert v-if="userStore.user?.LOAs?.[0]" class="m-2 mx-auto w-5xl" variant="info">
|
||||
<Alert v-if="userStore.user?.LOAs?.[0]" class="m-2 mx-auto max-w-5xl" variant="info">
|
||||
<AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto">
|
||||
<p>You are on LOA until <strong>{{ formatDate(userStore.user?.LOAs?.[0].extended_till ||
|
||||
userStore.user?.LOAs?.[0].end_date) }}</strong></p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import Separator from '../ui/separator/Separator.vue';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import {
|
||||
@@ -18,10 +18,9 @@ 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, ChevronDown, ChevronUp, CircleArrowOutUpRight, LogIn, LogOut, Menu, Settings, X } from 'lucide-vue-next';
|
||||
import { ArrowUpRight, CircleArrowOutUpRight } from 'lucide-vue-next';
|
||||
import DropdownMenuGroup from '../ui/dropdown-menu/DropdownMenuGroup.vue';
|
||||
import DropdownMenuSeparator from '../ui/dropdown-menu/DropdownMenuSeparator.vue';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const auth = useAuth();
|
||||
@@ -41,135 +40,11 @@ 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: 'Role Management',
|
||||
to: '/administration/roles',
|
||||
roles: ['17th Administrator']
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
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 === 'guest';
|
||||
} else if (item.status === 'member') {
|
||||
// Show ONLY if logged in as member
|
||||
shouldShow = isLoggedIn && auth.accountStatus.value === '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="hidden lg:flex max-w-screen-3xl w-full mx-auto items-center justify-between pr-10 pl-7">
|
||||
<div class="max-w-screen-3xl w-full mx-auto flex items-center justify-between pr-10 pl-7">
|
||||
<!-- left side -->
|
||||
<div class="flex items-center gap-7">
|
||||
<RouterLink to="/">
|
||||
@@ -321,109 +196,6 @@ function mobileNavigateTo(to: string) {
|
||||
<a v-else :href="APIHOST + '/login'">Login</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- <Separator></Separator> -->
|
||||
</div>
|
||||
</template>
|
||||
@@ -31,9 +31,14 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(loaded, (value) => {
|
||||
if (value) emit('load');
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'reload'): void
|
||||
(e: 'load'): void
|
||||
(e: 'edit', event: CalendarEvent): void
|
||||
}>()
|
||||
|
||||
@@ -179,7 +184,7 @@ defineExpose({ forceReload })
|
||||
<template>
|
||||
<div v-if="loaded">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-3 border-b px-4 py-3 ">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border px-4 py-3 ">
|
||||
<h2 class="text-lg font-semibold break-after-all">
|
||||
{{ activeEvent?.name || 'Event' }}
|
||||
</h2>
|
||||
@@ -227,14 +232,14 @@ defineExpose({ forceReload })
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="isPast && userStore.state === 'member'" class="w-full">
|
||||
<ButtonGroup class="flex w-full">
|
||||
<Button variant="outline"
|
||||
<ButtonGroup class="flex w-full justify-center">
|
||||
<Button variant="outline" class="flex-1"
|
||||
:class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''"
|
||||
@click="setAttendance(CalendarAttendance.Attending)">Going</Button>
|
||||
<Button variant="outline"
|
||||
<Button variant="outline" class="flex-1"
|
||||
:class="myAttendance?.status === CalendarAttendance.Maybe ? 'border-2 !border-l-2 border-primary text-primary' : ''"
|
||||
@click="setAttendance(CalendarAttendance.Maybe)">Maybe</Button>
|
||||
<Button variant="outline"
|
||||
<Button variant="outline" class="flex-1"
|
||||
:class="myAttendance?.status === CalendarAttendance.NotAttending ? 'border-2 !border-l-2 border-primary text-primary' : ''"
|
||||
@click="setAttendance(CalendarAttendance.NotAttending)">Declined</Button>
|
||||
</ButtonGroup>
|
||||
@@ -259,7 +264,7 @@ defineExpose({ forceReload })
|
||||
<!-- Description -->
|
||||
<section class="space-y-2 w-full">
|
||||
<p class="text-lg font-semibold">Description</p>
|
||||
<p class="border bg-muted/50 px-3 py-2 rounded-lg min-h-24 my-2 whitespace-pre-line">
|
||||
<p class="border border-border bg-muted/50 px-3 py-2 rounded-lg min-h-24 my-2 whitespace-pre-line">
|
||||
{{ activeEvent.description }}
|
||||
</p>
|
||||
</section>
|
||||
@@ -273,8 +278,8 @@ defineExpose({ forceReload })
|
||||
<p>Declined <span class="ml-1">{{ attendanceStatusSummary.notAttending }}</span></p>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="flex flex-col border bg-muted/50 rounded-lg min-h-24 my-2">
|
||||
<div class="flex w-full pt-2 border-b *:w-full *:text-center *:pb-1 *:cursor-pointer">
|
||||
<div class="flex flex-col border border-border bg-muted/50 rounded-lg min-h-24 my-2">
|
||||
<div class="flex w-full pt-2 border-b border-border *:w-full *:text-center *:pb-1 *:cursor-pointer">
|
||||
<label :class="attendanceTab === 'Alpha' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
|
||||
@click="attendanceTab = 'Alpha'">Alpha {{ attendanceCountsByGroup.Alpha }}</label>
|
||||
<label :class="attendanceTab === 'Echo' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
|
||||
@@ -283,14 +288,14 @@ defineExpose({ forceReload })
|
||||
@click="attendanceTab = 'Other'">Other {{ attendanceCountsByGroup.Other }}</label>
|
||||
</div>
|
||||
<div class="pb-1 min-h-48">
|
||||
<div class="grid grid-cols-2 font-semibold text-muted-foreground border-b py-1 px-3 mb-2">
|
||||
<div class="grid grid-cols-2 font-semibold text-muted-foreground border-b border-border py-1 px-3 mb-2">
|
||||
<p>Name</p>
|
||||
<p class="text-right">Status</p>
|
||||
</div>
|
||||
|
||||
<div v-for="person in attendanceList" :key="person.member_id"
|
||||
class="grid grid-cols-2 py-1 *:px-3 hover:bg-muted">
|
||||
<div>
|
||||
class="grid grid-cols-3 py-1 *:px-3 hover:bg-muted">
|
||||
<div class="col-span-2">
|
||||
<MemberCard :member-id="person.member_id"></MemberCard>
|
||||
</div>
|
||||
<p :class="statusColor(person.status)" class="text-right">
|
||||
@@ -302,11 +307,14 @@ defineExpose({ forceReload })
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex justify-center h-full items-center">
|
||||
<Button variant="ghost" size="icon" @click="emit('close')">
|
||||
<div v-else class="relative flex justify-center items-center h-full">
|
||||
<!-- Close button (top-right) -->
|
||||
<Button variant="ghost" size="icon" class="absolute top-2 right-2" @click="emit('close')">
|
||||
<X class="size-5" />
|
||||
</Button>
|
||||
|
||||
<Spinner class="size-8"></Spinner>
|
||||
<!-- Spinner (centered) -->
|
||||
<Spinner class="size-8" />
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -75,15 +75,17 @@ function formatDate(date: Date): string {
|
||||
});
|
||||
}
|
||||
|
||||
function loaStatus(loa: LOARequest): "Upcoming" | "Active" | "Overdue" | "Closed" {
|
||||
function loaStatus(loa: LOARequest): "Upcoming" | "Active" | "Extended" | "Overdue" | "Closed" {
|
||||
if (loa.closed) return "Closed";
|
||||
|
||||
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);
|
||||
|
||||
if (now < start) return "Upcoming";
|
||||
if (now >= start && now <= end) return "Active";
|
||||
if (now >= start && (now <= end)) return "Active";
|
||||
if (now >= start && (now <= extension)) return "Extended";
|
||||
if (now > loa.extended_till || end) return "Overdue";
|
||||
|
||||
return "Overdue"; // fallback
|
||||
@@ -191,6 +193,7 @@ function setPage(pagenum: number) {
|
||||
<TableCell>
|
||||
<Badge v-if="loaStatus(post) === 'Upcoming'" class="bg-blue-400">Upcoming</Badge>
|
||||
<Badge v-else-if="loaStatus(post) === 'Active'" class="bg-green-500">Active</Badge>
|
||||
<Badge v-else-if="loaStatus(post) === 'Extended'" class="bg-green-500">Extended</Badge>
|
||||
<Badge v-else-if="loaStatus(post) === 'Overdue'" class="bg-yellow-400">Overdue</Badge>
|
||||
<Badge v-else class="bg-gray-400">Ended</Badge>
|
||||
</TableCell>
|
||||
@@ -232,27 +235,47 @@ function setPage(pagenum: number) {
|
||||
<TableRow v-if="expanded === post.id" @mouseenter="hoverID = post.id"
|
||||
@mouseleave="hoverID = null" :class="{ 'bg-muted/50 border-t-0': hoverID === post.id }">
|
||||
<TableCell :colspan="8" class="p-0">
|
||||
<div class="w-full p-3 mb-6 space-y-3">
|
||||
<div class="flex justify-between items-start gap-4">
|
||||
<div class="space-y-3 w-full">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="text-sm font-semibold text-foreground">
|
||||
Reason
|
||||
</h4>
|
||||
<Separator class="flex-1" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div
|
||||
class="rounded-lg border bg-muted/40 px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground w-full">
|
||||
{{ post.reason || 'No reason provided.' }}
|
||||
</div>
|
||||
<div class="w-full p-4 mb-6 space-y-4">
|
||||
|
||||
<!-- Dates -->
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Start</p>
|
||||
<p class="font-medium">
|
||||
{{ formatDate(post.start_date) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-muted-foreground">Original end</p>
|
||||
<p class="font-medium">
|
||||
{{ formatDate(post.end_date) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<p class="text-muted-foreground">Extended to</p>
|
||||
<p class="font-medium text-foreground">
|
||||
{{post.extended_till ? formatDate(post.extended_till) : 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="text-sm font-semibold text-foreground">
|
||||
Reason
|
||||
</h4>
|
||||
<Separator class="flex-1" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border bg-muted/40 px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
|
||||
{{ post.reason || 'No reason provided.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -129,7 +129,11 @@ 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">
|
||||
|
||||
@@ -26,6 +26,7 @@ import PopoverTrigger from "@/components/ui/popover/PopoverTrigger.vue";
|
||||
import PopoverContent from "@/components/ui/popover/PopoverContent.vue";
|
||||
import Combobox from '../ui/combobox/Combobox.vue'
|
||||
import Tooltip from '../tooltip/Tooltip.vue'
|
||||
import Spinner from '../ui/spinner/Spinner.vue'
|
||||
|
||||
|
||||
const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({
|
||||
@@ -67,19 +68,24 @@ function toMySQLDateTime(date: Date): string {
|
||||
.replace("T", " ") + "000"; // becomes → 2025-11-19 00:00:00.000000
|
||||
}
|
||||
|
||||
|
||||
function onSubmit(vals) {
|
||||
const submitting = ref(false);
|
||||
async function onSubmit(vals) {
|
||||
//catch double submit
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const clean: CourseEventDetails = {
|
||||
...vals,
|
||||
event_date: new Date(vals.event_date),
|
||||
}
|
||||
|
||||
postTrainingReport(clean).then((newID) => {
|
||||
await postTrainingReport(clean).then((newID) => {
|
||||
emit("submit", newID);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("There was an error submitting the training report", err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,7 +408,12 @@ const filteredMembers = computed(() => {
|
||||
</FieldGroup>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button type="button" variant="outline" @click="resetForm">Reset</Button>
|
||||
<Button type="submit" form="trainingForm">Submit</Button>
|
||||
<Button type="submit" form="trainingForm" :disabled="submitting" class="w-35">
|
||||
<span class="flex items-center gap-2" v-if="submitting">
|
||||
<Spinner></Spinner> Submitting…
|
||||
</span>
|
||||
<span v-else>Submit</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@@ -16,7 +16,7 @@ export const buttonVariants = cva(
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
"hover:bg-accent active:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
success:
|
||||
"bg-success text-success-foreground shadow-xs hover:bg-success/90",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
|
||||
@@ -155,7 +155,7 @@ onMounted(() => {
|
||||
<div>
|
||||
<CreateCalendarEvent ref="dialogRef" @reload="loadEvents(); eventViewRef.forceReload();"></CreateCalendarEvent>
|
||||
<div class="flex">
|
||||
<div class="flex-1 min-h-0 mt-5">
|
||||
<div class="flex-1 min-h-0 mt-5" :class="{ 'hidden md:block': panelOpen }">
|
||||
<div class="h-[80vh] min-h-0">
|
||||
<!-- calendar header -->
|
||||
<div class="flex items-center justify-between mx-5">
|
||||
@@ -208,10 +208,10 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<aside v-if="panelOpen"
|
||||
class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col overflow-auto scrollbar-themed"
|
||||
class="w-screen 3xl:w-lg 2xl:w-md lg:border-l bg-card text-foreground flex flex-col overflow-auto scrollbar-themed"
|
||||
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
|
||||
<ViewCalendarEvent ref="eventViewRef" :key="currentEventID" @close="() => { router.push('/calendar'); }"
|
||||
@reload="loadEvents()" @edit="(val) => { dialogRef.openDialog(null, 'edit', val) }">
|
||||
@reload="loadEvents()" @load="calendarRef.getApi().updateSize()" @edit="(val) => { dialogRef.openDialog(null, 'edit', val) }">
|
||||
</ViewCalendarEvent>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -1,48 +1,29 @@
|
||||
<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 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>
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:flex flex-row lg:max-h-[70vh] gap-8">
|
||||
<div class="flex flex-col lg: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
|
||||
@@ -60,4 +41,25 @@ const isMobileFormOpen = ref(false);
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
Reference in New Issue
Block a user