Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c1b132f8d | |||
| 4e69b419f0 | |||
| a233b15ad4 | |||
| 94b5bfcff7 | |||
| 09d20fd18f | |||
| 89280dd3a7 | |||
| 575455a0fc | |||
| ccb132b9b0 | |||
| e550f862bc | |||
| 6d6789c4a6 |
@@ -123,15 +123,9 @@ export async function setAttendanceStatus(memberID: number, eventID: number, sta
|
||||
}
|
||||
|
||||
export async function getEventAttendance(eventID: number): Promise<CalendarSignup[]> {
|
||||
const sql = `
|
||||
SELECT
|
||||
s.member_id,
|
||||
s.status,
|
||||
m.name AS member_name
|
||||
FROM calendar_events_signups s
|
||||
LEFT JOIN members m ON s.member_id = m.id
|
||||
WHERE s.event_id = ?
|
||||
`;
|
||||
|
||||
return await pool.query(sql, [eventID]);
|
||||
const sql = "CALL `sp_GetCalendarEventSignups`(?)"
|
||||
const res = await pool.query(sql, [eventID]);
|
||||
console.log(res[0]);
|
||||
return res[0];
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export interface CalendarSignup {
|
||||
eventID: number;
|
||||
status: CalendarAttendance;
|
||||
member_name?: string;
|
||||
member_unit?: string;
|
||||
}
|
||||
|
||||
export interface CalendarEventShort {
|
||||
|
||||
@@ -156,6 +156,12 @@ function blurAfter() {
|
||||
<RouterLink to="/join" @click="blurAfter">Join</RouterLink>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<!-- Calendar -->
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
|
||||
<RouterLink to="/calendar" @click="blurAfter">Calendar</RouterLink>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { CalendarEvent, CalendarSignup } from '@shared/types/calendar'
|
||||
import { CircleAlert, Clock, EllipsisVertical, MapPin, User, X } from 'lucide-vue-next';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { CircleAlert, Clock4, EllipsisVertical, MapPin, User, X } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
|
||||
import Button from '../ui/button/Button.vue';
|
||||
import { CalendarAttendance, getCalendarEvent, setCalendarEventAttendance, setCancelCalendarEvent } from '@/api/calendar';
|
||||
@@ -11,24 +11,13 @@ import DropdownMenu from '../ui/dropdown-menu/DropdownMenu.vue';
|
||||
import DropdownMenuTrigger from '../ui/dropdown-menu/DropdownMenuTrigger.vue';
|
||||
import DropdownMenuContent from '../ui/dropdown-menu/DropdownMenuContent.vue';
|
||||
import DropdownMenuItem from '../ui/dropdown-menu/DropdownMenuItem.vue';
|
||||
import { Calendar } from 'lucide-vue-next';
|
||||
|
||||
const route = useRoute();
|
||||
// const eventID = computed(() => {
|
||||
// const id = route.params.id;
|
||||
// if (typeof id === 'string') return id;
|
||||
// return undefined;
|
||||
// });
|
||||
|
||||
const loaded = ref<boolean>(false);
|
||||
const activeEvent = ref<CalendarEvent | null>(null);
|
||||
|
||||
// onMounted(async () => {
|
||||
// let eventID = route.params.id;
|
||||
// console.log(eventID);
|
||||
// activeEvent.value = await getCalendarEvent(Number(eventID));
|
||||
// loaded.value = true;
|
||||
// });
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (id) => {
|
||||
@@ -45,23 +34,27 @@ const emit = defineEmits<{
|
||||
(e: 'edit', event: CalendarEvent): void
|
||||
}>()
|
||||
|
||||
// const activeEvent = computed(() => props.event)
|
||||
|
||||
const startFmt = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit'
|
||||
const dateFmt = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'long', month: 'short', day: 'numeric',
|
||||
})
|
||||
const endFmt = new Intl.DateTimeFormat(undefined, {
|
||||
|
||||
const timeFmt = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric', minute: '2-digit'
|
||||
})
|
||||
|
||||
const whenText = computed(() => {
|
||||
if (!activeEvent.value?.start) return ''
|
||||
const s = new Date(activeEvent.value.start)
|
||||
const e = activeEvent.value?.end ? new Date(activeEvent.value.end) : null
|
||||
return e
|
||||
? `${startFmt.format(s)} – ${endFmt.format(e)}`
|
||||
: `${startFmt.format(s)}`
|
||||
const dateText = computed(() => {
|
||||
let start = dateFmt.format(new Date(activeEvent.value.start));
|
||||
let end = dateFmt.format(new Date(activeEvent.value.end));
|
||||
if (start === end)
|
||||
return start;
|
||||
else
|
||||
return `${start} - ${end}`;
|
||||
})
|
||||
|
||||
const timeText = computed(() => {
|
||||
let startTime = timeFmt.format(new Date(activeEvent.value.start))
|
||||
let endTime = timeFmt.format(new Date(activeEvent.value.end))
|
||||
return [startTime, endTime]
|
||||
})
|
||||
|
||||
const attending = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Attending) })
|
||||
@@ -71,6 +64,7 @@ const viewedState = ref<CalendarAttendance>(CalendarAttendance.Attending);
|
||||
|
||||
let user = useUserStore();
|
||||
const myAttendance = computed<CalendarSignup | null>(() => {
|
||||
if (!user.isLoggedIn) return null;
|
||||
return activeEvent.value.eventSignups.find(
|
||||
(s) => s.member_id === user.user.id
|
||||
) || null;
|
||||
@@ -83,6 +77,7 @@ async function setAttendance(state: CalendarAttendance) {
|
||||
}
|
||||
|
||||
const canEditEvent = computed(() => {
|
||||
if (!user.isLoggedIn) return false;
|
||||
if (user.user.id == activeEvent.value.creator_id)
|
||||
return true;
|
||||
});
|
||||
@@ -97,17 +92,94 @@ async function forceReload() {
|
||||
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
|
||||
}
|
||||
|
||||
const isPast = computed(() => {
|
||||
const end = new Date(activeEvent.value.end)
|
||||
// is current date later than end date
|
||||
return new Date() < end;
|
||||
})
|
||||
|
||||
const attendanceTab = ref<"Alpha" | "Echo" | "Other">("Alpha");
|
||||
const attendanceList = computed<CalendarSignup[]>(() => {
|
||||
let out: CalendarSignup[] = [];
|
||||
if (attendanceTab.value === 'Alpha') {
|
||||
out = activeEvent.value.eventSignups?.filter((s) => s.member_unit === 'Alpha Company');
|
||||
} else if (attendanceTab.value === 'Echo') {
|
||||
out = activeEvent.value.eventSignups?.filter((s) => s.member_unit === 'Echo Company')
|
||||
} else {
|
||||
out = activeEvent.value.eventSignups?.filter((s) => s.member_unit != 'Alpha Company' && s.member_unit != 'Echo Company')
|
||||
}
|
||||
|
||||
const statusOrder: Record<CalendarAttendance, number> = {
|
||||
[CalendarAttendance.Attending]: 1,
|
||||
[CalendarAttendance.Maybe]: 2,
|
||||
[CalendarAttendance.NotAttending]: 3,
|
||||
};
|
||||
|
||||
out.sort((a, b) => statusOrder[a.status] - statusOrder[b.status]);
|
||||
|
||||
return out;
|
||||
})
|
||||
|
||||
const attendanceCountsByGroup = computed(() => {
|
||||
const signups = activeEvent.value.eventSignups ?? [];
|
||||
|
||||
return {
|
||||
Alpha: signups.filter(s => s.member_unit === "Alpha Company").length,
|
||||
Echo: signups.filter(s => s.member_unit === "Echo Company").length,
|
||||
Other: signups.filter(s =>
|
||||
s.member_unit !== "Alpha Company" &&
|
||||
s.member_unit !== "Echo Company"
|
||||
).length,
|
||||
};
|
||||
});
|
||||
|
||||
const attendanceStatusSummary = computed(() => {
|
||||
const signups = activeEvent.value.eventSignups ?? [];
|
||||
|
||||
return {
|
||||
attending: signups.filter(s => s.status === CalendarAttendance.Attending).length,
|
||||
maybe: signups.filter(s => s.status === CalendarAttendance.Maybe).length,
|
||||
notAttending: signups.filter(s => s.status === CalendarAttendance.NotAttending).length,
|
||||
};
|
||||
});
|
||||
|
||||
const statusColor = (status: CalendarAttendance) => {
|
||||
switch (status) {
|
||||
case CalendarAttendance.Attending:
|
||||
return "text-success";
|
||||
case CalendarAttendance.Maybe:
|
||||
return "text-yellow-600";
|
||||
case CalendarAttendance.NotAttending:
|
||||
return "text-destructive";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const displayStatus = (status: CalendarAttendance) => {
|
||||
switch (status) {
|
||||
case CalendarAttendance.Attending:
|
||||
return "Attending";
|
||||
case CalendarAttendance.Maybe:
|
||||
return "Maybe";
|
||||
case CalendarAttendance.NotAttending:
|
||||
return "Declined";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ forceReload })
|
||||
</script>
|
||||
|
||||
<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 px-4 py-3 h-14">
|
||||
<h2 class="text-lg font-semibold break-all">
|
||||
{{ activeEvent?.name || 'Event' }}
|
||||
</h2>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex gap-4 items-center">
|
||||
<DropdownMenu v-if="canEditEvent">
|
||||
<DropdownMenuTrigger>
|
||||
<button
|
||||
@@ -119,8 +191,7 @@ defineExpose({forceReload})
|
||||
<DropdownMenuItem @click="emit('edit', activeEvent)">
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="activeEvent.cancelled"
|
||||
@click="setCancel(false)">
|
||||
<DropdownMenuItem v-if="activeEvent.cancelled" @click="setCancel(false)">
|
||||
Un-Cancel
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-else @click="setCancel(true)">
|
||||
@@ -142,7 +213,7 @@ defineExpose({forceReload})
|
||||
<CircleAlert></CircleAlert> This event has been cancelled
|
||||
</div>
|
||||
</section>
|
||||
<section class="w-full">
|
||||
<section v-if="isPast && user.isLoggedIn" class="w-full">
|
||||
<ButtonGroup class="flex w-full">
|
||||
<Button variant="outline"
|
||||
:class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''"
|
||||
@@ -155,24 +226,21 @@ defineExpose({forceReload})
|
||||
@click="setAttendance(CalendarAttendance.NotAttending)">Declined</Button>
|
||||
</ButtonGroup>
|
||||
</section>
|
||||
<!-- When -->
|
||||
<section v-if="whenText" class="space-y-2 w-full">
|
||||
<div class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
|
||||
<Clock class="size-4 opacity-80" />
|
||||
<span class="font-medium">{{ whenText }}</span>
|
||||
<!-- Meta -->
|
||||
<section class="space-y-3 w-full">
|
||||
<p class="text-lg font-semibold">Details</p>
|
||||
<div class="text-foreground/80 flex gap-3 items-center">
|
||||
<Calendar :size="20"></Calendar> {{ dateText }}
|
||||
</div>
|
||||
<div class="text-foreground/80 flex gap-3 items-center">
|
||||
<Clock4 :size="20"></Clock4> {{ timeText[0] }} - {{ timeText[1] }}
|
||||
</div>
|
||||
<div class="text-foreground/80 flex gap-3 items-center">
|
||||
<MapPin :size="20"></MapPin> {{ activeEvent.location || "Unknown" }}
|
||||
</div>
|
||||
<div class="text-foreground/80 flex gap-3 items-center">
|
||||
<User :size="20"></User> {{ activeEvent.creator_name || "Unknown User" }}
|
||||
</div>
|
||||
</section>
|
||||
<!-- Quick meta chips -->
|
||||
<section class="flex flex-wrap gap-2 w-full">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
|
||||
<MapPin class="size-3.5 opacity-80" />
|
||||
<span class="font-medium">{{ activeEvent.location || "Unknown" }}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
|
||||
<User class="size-3.5 opacity-80" />
|
||||
<span class="font-medium">Created by: {{ activeEvent.creator_name || "Unknown User"
|
||||
}}</span>
|
||||
</span>
|
||||
</section>
|
||||
<!-- Description -->
|
||||
<section class="space-y-2 w-full">
|
||||
@@ -181,46 +249,41 @@ defineExpose({forceReload})
|
||||
{{ activeEvent.description }}
|
||||
</p>
|
||||
</section>
|
||||
<!-- Attendance -->
|
||||
<!-- attendance -->
|
||||
<section class="space-y-2 w-full">
|
||||
<div class="flex items-center gap-5">
|
||||
<p class="text-lg font-semibold">Attendance</p>
|
||||
<!-- <div class="text-muted-foreground flex gap-6">
|
||||
<p>Going <span class="ml-1">{{ attendanceStatusSummary.attending }}</span></p>
|
||||
<p>Maybe <span class="ml-1">{{ attendanceStatusSummary.maybe }}</span></p>
|
||||
<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">
|
||||
<label
|
||||
:class="viewedState === CalendarAttendance.Attending ? 'border-b-3 border-foreground' : 'mb-[2px]'"
|
||||
@click="viewedState = CalendarAttendance.Attending">Going {{ attending.length }}</label>
|
||||
<label
|
||||
:class="viewedState === CalendarAttendance.Maybe ? 'border-b-3 border-foreground' : 'mb-[2px]'"
|
||||
@click="viewedState = CalendarAttendance.Maybe">Maybe {{ maybe.length }}</label>
|
||||
<label
|
||||
:class="viewedState === CalendarAttendance.NotAttending ? 'border-b-3 border-foreground' : 'mb-[2px]'"
|
||||
@click="viewedState = CalendarAttendance.NotAttending">Declined {{ declined.length
|
||||
}}</label>
|
||||
<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]'"
|
||||
@click="attendanceTab = 'Echo'">Echo {{ attendanceCountsByGroup.Echo }}</label>
|
||||
<label :class="attendanceTab === 'Other' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
|
||||
@click="attendanceTab = 'Other'">Other {{ attendanceCountsByGroup.Other }}</label>
|
||||
</div>
|
||||
<div class="px-5 py-4 min-h-28">
|
||||
<div v-if="viewedState === CalendarAttendance.Attending" v-for="person in attending">
|
||||
<p>{{ person.member_name }}</p>
|
||||
<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">
|
||||
<p>Name</p>
|
||||
<p class="text-right">Status</p>
|
||||
</div>
|
||||
<div v-if="viewedState === CalendarAttendance.Maybe" v-for="person in maybe">
|
||||
<p>{{ person.member_name }}</p>
|
||||
</div>
|
||||
<div v-if="viewedState === CalendarAttendance.NotAttending" v-for="person in declined">
|
||||
|
||||
<div v-for="person in attendanceList" :key="person.member_id"
|
||||
class="grid grid-cols-2 py-1 *:px-3 hover:bg-muted">
|
||||
<p>{{ person.member_name }}</p>
|
||||
<p :class="statusColor(person.status)" class="text-right">
|
||||
{{ displayStatus(person.status) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Footer (optional actions) -->
|
||||
<!-- <div class="border-t px-4 py-3 flex items-center justify-end gap-2">
|
||||
<button class="rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40 transition">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm hover:opacity-90 transition">
|
||||
Open details
|
||||
</button>
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,15 +3,14 @@ import { ref, watch, nextTick, computed, onMounted } from 'vue'
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import { X, Clock, MapPin, User, ListTodo, ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next'
|
||||
import { ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next'
|
||||
import CreateCalendarEvent from '@/components/calendar/CreateCalendarEvent.vue'
|
||||
import { getCalendarEvent, getMonthCalendarEvents } from '@/api/calendar'
|
||||
import { CalendarEvent, CalendarEventShort } from '@shared/types/calendar'
|
||||
import { Calendar } from '@fullcalendar/core'
|
||||
import { CalendarEvent } from '@shared/types/calendar'
|
||||
import ViewCalendarEvent from '@/components/calendar/ViewCalendarEvent.vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useCalendarEvents } from '@/composables/useCalendarEvents'
|
||||
import { useCalendarNavigation } from '@/composables/useCalendarNavigation'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const monthLabels = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
@@ -24,6 +23,7 @@ function api() {
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
|
||||
function buildFullDate(month: number, year: number): Date {
|
||||
return new Date(year, month, 1); //default to first of month
|
||||
@@ -48,6 +48,7 @@ const dialogRef = ref<any>(null)
|
||||
|
||||
// NEW: handle day/time slot clicks to start creating an event
|
||||
function onDateClick(arg: { dateStr: string }) {
|
||||
if (!userStore.isLoggedIn) return;
|
||||
dialogRef.value?.openDialog(arg.dateStr);
|
||||
// For now, just open the panel with a draft payload.
|
||||
// activeEvent.value = {
|
||||
@@ -202,7 +203,7 @@ onMounted(() => {
|
||||
@click="goToday">
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
<button v-if="userStore.isLoggedIn"
|
||||
class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
|
||||
@click="onCreateEvent">
|
||||
<Plus class="h-4 w-4" />
|
||||
@@ -216,7 +217,8 @@ onMounted(() => {
|
||||
<aside v-if="panelOpen"
|
||||
class="3xl:w-lg 2xl:w-md 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" @close="() => { router.push('/calendar'); }" @reload="loadEvents()" @edit="(val) => {dialogRef.openDialog(null, 'edit', val)}">
|
||||
<ViewCalendarEvent ref="eventViewRef" @close="() => { router.push('/calendar'); }"
|
||||
@reload="loadEvents()" @edit="(val) => { dialogRef.openDialog(null, 'edit', val) }">
|
||||
</ViewCalendarEvent>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,8 @@ const router = createRouter({
|
||||
{ path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } },
|
||||
{ path: '/transfer', component: () => import('@/pages/Transfer.vue'), meta: { requiresAuth: true, memberOnly: true } },
|
||||
|
||||
{ path: '/calendar', component: () => import('@/pages/Calendar.vue'), meta: { requiresAuth: true, memberOnly: true }, },
|
||||
{ path: '/calendar/event/:id', component: () => import('@/pages/Calendar.vue'), meta: { requiresAuth: true, memberOnly: true }, },
|
||||
{ path: '/calendar', component: () => import('@/pages/Calendar.vue'), meta: { }, },
|
||||
{ path: '/calendar/event/:id', component: () => import('@/pages/Calendar.vue'), meta: { }, },
|
||||
|
||||
{ path: '/trainingReport', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
|
||||
{ path: '/trainingReport/new', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
|
||||
|
||||
Reference in New Issue
Block a user