Hooked up calendar viewing to API, still needs a lot more polish

This commit is contained in:
2025-11-23 17:00:47 -05:00
parent b8bf809c14
commit 531371d059
11 changed files with 312 additions and 240 deletions

View File

@@ -46,6 +46,7 @@ const { status, memberStatus } = require('./routes/statuses')
const authRouter = require('./routes/auth') const authRouter = require('./routes/auth')
const { roles, memberRoles } = require('./routes/roles'); const { roles, memberRoles } = require('./routes/roles');
const { courseRouter, eventRouter } = require('./routes/course'); const { courseRouter, eventRouter } = require('./routes/course');
const { calendarRouter } = require('./routes/calendar')
const morgan = require('morgan'); const morgan = require('morgan');
app.use('/application', applicationsRouter); app.use('/application', applicationsRouter);
@@ -59,6 +60,7 @@ app.use('/roles', roles)
app.use('/memberRoles', memberRoles) app.use('/memberRoles', memberRoles)
app.use('/course', courseRouter) app.use('/course', courseRouter)
app.use('/courseEvent', eventRouter) app.use('/courseEvent', eventRouter)
app.use('/calendar', calendarRouter)
app.use('/', authRouter) app.use('/', authRouter)
app.get('/ping', (req, res) => { app.get('/ping', (req, res) => {

View File

@@ -1,4 +1,6 @@
import { Request, Response } from "express";
import { getEventAttendance, getEventDetails, getShortEventsInRange } from "../services/calendarService"; import { getEventAttendance, getEventDetails, getShortEventsInRange } from "../services/calendarService";
import { CalendarEvent } from "@app/shared/types/calendar";
const express = require('express'); const express = require('express');
const r = express.Router(); const r = express.Router();
@@ -9,16 +11,24 @@ function addMonths(date: Date, months: number): Date {
return d return d
} }
//get calendar events paged //get calendar events paged, requires a query string with from= and to= as mariadb ISO strings
r.get('/', async (req, res) => { r.get('/', async (req, res) => {
const viewDate: Date = req.body.date; try {
//generate date range const fromDate: string = req.query.from;
const backDate: Date = addMonths(viewDate, -1); const toDate: string = req.query.to;
const frontDate: Date = addMonths(viewDate, 2);
const events = getShortEventsInRange(backDate, frontDate); if (fromDate === undefined || toDate === undefined) {
res.status(400).send("Missing required query parameters 'from' and 'to'");
return;
}
res.status(200).json(events); const events = await getShortEventsInRange(fromDate, toDate);
res.status(200).json(events);
} catch (error) {
console.error('Error fetching calendar events:', error);
res.status(500).send('Error fetching calendar events');
}
}); });
r.get('/upcoming', async (req, res) => { r.get('/upcoming', async (req, res) => {
@@ -26,16 +36,14 @@ r.get('/upcoming', async (req, res) => {
}) })
//get event details //get event details
r.get('/:id', async (req, res) => { r.get('/:id', async (req: Request, res: Response) => {
try { try {
const eventID: number = req.params.id; const eventID: number = Number(req.params.id);
let details = getEventDetails(eventID); let details: CalendarEvent = await getEventDetails(eventID);
let attendance = await getEventAttendance(eventID); details.eventSignups = await getEventAttendance(eventID);
console.log(details);
let out = { ...details, attendance } res.status(200).json(details);
console.log(out);
res.status(200).json(out);
} catch (err) { } catch (err) {
console.error('Insert failed:', err); console.error('Insert failed:', err);
res.status(500).json(err); res.status(500).json(err);
@@ -47,4 +55,4 @@ r.post('/', async (req, res) => {
}) })
module.exports.calendar = r; module.exports.calendarRouter = r;

View File

@@ -1,6 +0,0 @@
export declare function createEvent(eventObject: any): Promise<void>;
export declare function updateEvent(eventObject: any): Promise<void>;
export declare function cancelEvent(eventID: any): Promise<void>;
export declare function getShortEventsInRange(startDate: any, endDate: any): Promise<void>;
export declare function getEventDetailed(eventID: any): Promise<void>;
//# sourceMappingURL=calendarService.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"calendarService.d.ts","sourceRoot":"","sources":["calendarService.ts"],"names":[],"mappings":"AAEA,wBAAsB,WAAW,CAAC,WAAW,KAAA,iBAE5C;AAED,wBAAsB,WAAW,CAAC,WAAW,KAAA,iBAE5C;AAED,wBAAsB,WAAW,CAAC,OAAO,KAAA,iBAExC;AAED,wBAAsB,qBAAqB,CAAC,SAAS,KAAA,EAAE,OAAO,KAAA,iBAE7D;AAED,wBAAsB,gBAAgB,CAAC,OAAO,KAAA,iBAE7C"}

View File

@@ -1,19 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createEvent = createEvent;
exports.updateEvent = updateEvent;
exports.cancelEvent = cancelEvent;
exports.getShortEventsInRange = getShortEventsInRange;
exports.getEventDetailed = getEventDetailed;
const pool = require('../db');
async function createEvent(eventObject) {
}
async function updateEvent(eventObject) {
}
async function cancelEvent(eventID) {
}
async function getShortEventsInRange(startDate, endDate) {
}
async function getEventDetailed(eventID) {
}
//# sourceMappingURL=calendarService.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"calendarService.js","sourceRoot":"","sources":["calendarService.ts"],"names":[],"mappings":";;AAEA,kCAEC;AAED,kCAEC;AAED,kCAEC;AAED,sDAEC;AAED,4CAEC;AApBD,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;AAEtB,KAAK,UAAU,WAAW,CAAC,WAAW;AAE7C,CAAC;AAEM,KAAK,UAAU,WAAW,CAAC,WAAW;AAE7C,CAAC;AAEM,KAAK,UAAU,WAAW,CAAC,OAAO;AAEzC,CAAC;AAEM,KAAK,UAAU,qBAAqB,CAAC,SAAS,EAAE,OAAO;AAE9D,CAAC;AAEM,KAAK,UAAU,gBAAgB,CAAC,OAAO;AAE9C,CAAC"}

View File

@@ -1,18 +1,5 @@
import pool from '../db'; import pool from '../db';
import { CalendarEventShort, CalendarSignup, CalendarEvent } from "@app/shared/types/calendar"
export interface CalendarEvent {
id: number;
name: string;
start: Date; // DATETIME -> Date
end: Date; // DATETIME -> Date
location: string;
color: string; // 7 character hex string
description?: string | null;
creator?: number | null; // foreign key to members.id, nullable
cancelled: boolean; // TINYINT(1) -> boolean
created_at: Date; // TIMESTAMP -> Date
updated_at: Date; // TIMESTAMP -> Date
}
export type Attendance = 'attending' | 'maybe' | 'not_attending'; export type Attendance = 'attending' | 'maybe' | 'not_attending';
@@ -29,7 +16,7 @@ export async function createEvent(eventObject: Omit<CalendarEvent, 'id' | 'creat
eventObject.location, eventObject.location,
eventObject.color, eventObject.color,
eventObject.description ?? null, eventObject.description ?? null,
eventObject.creator, eventObject.creator_id,
]; ];
const result = await pool.query(sql, params); const result = await pool.query(sql, params);
@@ -78,17 +65,19 @@ export async function cancelEvent(eventID: number) {
} }
export async function getShortEventsInRange(startDate: Date, endDate: Date) { export async function getShortEventsInRange(startDate: string, endDate: string): Promise<CalendarEventShort[]> {
const sql = ` const sql = `
SELECT id, name, start, end, color SELECT id, name, start, end, color
FROM calendar_events FROM calendar_events
WHERE start BETWEEN ? AND ? WHERE start BETWEEN ? AND ?
ORDER BY start ASC ORDER BY start ASC
`; `;
return await pool.query(sql, [startDate, endDate]); const res: CalendarEventShort[] = await pool.query(sql, [startDate, endDate]);
console.log(res);
return res;
} }
export async function getEventDetails(eventID: number) { export async function getEventDetails(eventID: number): Promise<CalendarEvent> {
const sql = ` const sql = `
SELECT SELECT
e.id, e.id,
@@ -101,14 +90,14 @@ export async function getEventDetails(eventID: number) {
e.cancelled, e.cancelled,
e.created_at, e.created_at,
e.updated_at, e.updated_at,
m.id AS creator_id, e.creator AS creator_id,
m.name AS creator_name m.name AS creator_name
FROM calendar_events e FROM calendar_events e
LEFT JOIN members m ON e.creator = m.id LEFT JOIN members m ON e.creator = m.id
WHERE e.id = ? WHERE e.id = ?
`; `;
let vals: CalendarEvent[] = await pool.query(sql, [eventID]);
return await pool.query(sql, [eventID]) return vals[0];
} }
export async function getUpcomingEvents(date: Date, limit: number) { export async function getUpcomingEvents(date: Date, limit: number) {
@@ -135,7 +124,7 @@ export async function setAttendanceStatus(memberID: number, eventID: number, sta
return { success: true } return { success: true }
} }
export async function getEventAttendance(eventID: number) { export async function getEventAttendance(eventID: number): Promise<CalendarSignup[]> {
const sql = ` const sql = `
SELECT SELECT
s.member_id, s.member_id,

36
shared/types/calendar.ts Normal file
View File

@@ -0,0 +1,36 @@
export interface CalendarEvent {
id: number;
name: string;
start: Date;
end: Date;
location: string;
color: string;
description: string;
creator_id: number;
cancelled: boolean;
created_at: Date;
updated_at: Date;
creator_name?: string | null;
eventSignups?: CalendarSignup[] | null;
}
export enum CalendarAttendance {
Attending = "attending",
NotAttending = "not_attending",
Maybe = "maybe"
}
export interface CalendarSignup {
memberID: number;
eventID: number;
state: CalendarAttendance;
}
export interface CalendarEventShort {
id: number;
name: string;
start: Date;
end: Date;
color: string;
}

11
shared/utils/time.ts Normal file
View File

@@ -0,0 +1,11 @@
export function toDateTime(date: Date): string {
// This produces a CST-local time because server runs in CST
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
const hour = date.getHours().toString().padStart(2, "0");
const minute = date.getMinutes().toString().padStart(2, "0");
const second = date.getSeconds().toString().padStart(2, "0");
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}

View File

@@ -1,13 +1,13 @@
export interface CalendarEvent { // export interface CalendarEvent {
name: string, // name: string,
start: Date, // start: Date,
end: Date, // end: Date,
location: string, // location: string,
color: string, // color: string,
description: string, // description: string,
creator: any | null, // user object // creator: any | null, // user object
id: number | null // id: number | null
} // }
export enum CalendarAttendance { export enum CalendarAttendance {
Attending = "attending", Attending = "attending",
@@ -21,6 +21,52 @@ export interface CalendarSignup {
state: CalendarAttendance state: CalendarAttendance
} }
import { CalendarEventShort, CalendarEvent } from "@shared/types/calendar";
//@ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getMonthCalendarEvents(viewedMonth: Date): Promise<CalendarEventShort[]> {
const year = viewedMonth.getFullYear();
const month = viewedMonth.getMonth();
// Base range: first and last day of the month
const firstOfMonth = new Date(year, month, 1);
const lastOfMonth = new Date(year, month + 1, 0);
// --- Apply 10 day padding ---
const start = new Date(firstOfMonth);
start.setDate(start.getDate() - 10);
const end = new Date(lastOfMonth);
end.setDate(end.getDate() + 10);
end.setHours(23, 59, 59, 999);
const from = start.toISOString();
const to = end.toISOString();
const url = `${addr}/calendar?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch events: ${res.status} ${res.statusText}`);
}
return res.json();
}
export async function getCalendarEvent(id: number): Promise<CalendarEvent> {
let res = await fetch(`${addr}/calendar/${id}`);
if(res.ok) {
return await res.json();
} else {
throw new Error(`Failed to fetch event: ${res.status} ${res.statusText}`);
}
}
export async function createCalendarEvent(eventData: CalendarEvent) { export async function createCalendarEvent(eventData: CalendarEvent) {
} }

View File

@@ -5,6 +5,9 @@ import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction' import interactionPlugin from '@fullcalendar/interaction'
import { X, Clock, MapPin, User, ListTodo, ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next' import { X, Clock, MapPin, User, ListTodo, ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next'
import CreateCalendarEvent from '@/components/calendar/CreateCalendarEvent.vue' 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'
const monthLabels = [ const monthLabels = [
'January', 'February', 'March', 'April', 'May', 'June', 'January', 'February', 'March', 'April', 'May', 'June',
@@ -30,15 +33,18 @@ function buildFullDate(month: number, year: number): Date {
return new Date(year, month, 1); //default to first of month return new Date(year, month, 1); //default to first of month
} }
watch([selectedMonth, selectedYear], async () => {
watch([selectedMonth, selectedYear], () => {
console.log('Selected date changed:', selectedMonth.value, selectedYear.value) console.log('Selected date changed:', selectedMonth.value, selectedYear.value)
let monthEvents = await getMonthCalendarEvents(buildFullDate(selectedMonth.value, selectedYear.value));
events.value = monthEvents.map(toCalEvent);
console.log(events.value);
}) })
onMounted(() => { onMounted(async () => {
// fetchEventsFor(selectedMonth.value, selectedYear.value) let monthEvents = await getMonthCalendarEvents(buildFullDate(selectedMonth.value, selectedYear.value));
events.value = monthEvents.map(toCalEvent);
console.log(events.value);
}) })
@@ -54,28 +60,41 @@ function goToSelectedDate() {
type CalEvent = { type CalEvent = {
id: string id: string
title: string title: string
start: string start: Date
end?: string end?: Date
extendedProps?: Record<string, any> extendedProps?: Record<string, any>
} }
const events = ref<CalEvent[]>([ function toCalEvent(e: CalendarEventShort): CalEvent {
{ id: '1', title: 'Squad Training', start: '2025-10-08T19:00:00', extendedProps: { trainer: 'Alex', location: 'Range A', color: '#88C4FF' } }, return {
{ id: '2', title: 'Ops Briefing', start: '2025-10-09T20:30:00', extendedProps: { owner: 'CO', agenda: ['Weather', 'Route', 'Risks'], color: '#dba42c' } }, id: e.id.toString(),
]) title: e.name,
start: e.start,
end: e.end,
extendedProps: {
color: e.color
}
}
}
const events = ref<CalEvent[]>([])
const panelOpen = ref(false) const panelOpen = ref(false)
const activeEvent = ref<CalEvent | null>(null) const activeEvent = ref<CalendarEvent | null>(null)
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null) const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null)
function onEventClick(arg: any) { async function onEventClick(arg: any) {
activeEvent.value = { const targetEvent = arg.event.id;
id: arg.event.id, activeEvent.value = await getCalendarEvent(targetEvent);
title: arg.event.title, console.log(activeEvent.value);
start: arg.event.startStr, // activeEvent.value = {
end: arg.event.endStr, // id: arg.event.id,
extendedProps: arg.event.extendedProps // title: arg.event.title,
} // start: arg.event.startStr,
// end: arg.event.endStr,
// extendedProps: arg.event.extendedProps
// }
panelOpen.value = true panelOpen.value = true
} }
@@ -123,6 +142,7 @@ const calendarOptions = ref({
// custom renderer -> one-line pill // custom renderer -> one-line pill
eventContent(arg) { eventContent(arg) {
console.log
const ext = arg.event.extendedProps || {} const ext = arg.event.extendedProps || {}
const c = ext.color || arg.backgroundColor || arg.borderColor || '' const c = ext.color || arg.backgroundColor || arg.borderColor || ''
@@ -183,156 +203,143 @@ onMounted(() => {
onDatesSet() onDatesSet()
}) })
const ext = computed(() => activeEvent.value?.extendedProps ?? {}) // const ext = computed(() => activeEvent.value?.extendedProps ?? {})
</script> </script>
<template> <template>
<CreateCalendarEvent ref="dialogRef"></CreateCalendarEvent> <div>
<CreateCalendarEvent ref="dialogRef"></CreateCalendarEvent>
<div class="flex"> <div class="flex">
<div class="flex-1 min-h-0 mt-5"> <div class="flex-1 min-h-0 mt-5">
<div class="h-[80vh] min-h-0"> <div class="h-[80vh] min-h-0">
<!-- calendar header --> <!-- calendar header -->
<div class="flex items-center justify-between mx-5"> <div class="flex items-center justify-between mx-5">
<!-- Left: title + pickers --> <!-- Left: title + pickers -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- <h2 class="text-xl font-semibold tracking-tight"> <!-- <h2 class="text-xl font-semibold tracking-tight">
{{ monthLabels[selectedMonth] }} {{ selectedYear }} {{ monthLabels[selectedMonth] }} {{ selectedYear }}
</h2> --> </h2> -->
<!-- Month dropdown -->
<!-- Month dropdown --> <select v-model.number="selectedMonth" @change="goToSelectedDate"
<select v-model.number="selectedMonth" @change="goToSelectedDate" class="ml-2 rounded-md border bg-transparent px-2 py-1 text-sm"
class="ml-2 rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select month"> aria-label="Select month">
<option v-for="(m, i) in monthLabels" :key="m" :value="i" class="bg-card"> <option v-for="(m, i) in monthLabels" :key="m" :value="i" class="bg-card">
{{ m }} {{ m }}
</option> </option>
</select> </select>
<!-- Year dropdown -->
<!-- Year dropdown --> <select v-model.number="selectedYear" @change="goToSelectedDate"
<select v-model.number="selectedYear" @change="goToSelectedDate" class="rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select year">
class="rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select year"> <option v-for="y in years" :key="y" :value="y" class="bg-card">
<option v-for="y in years" :key="y" :value="y" class="bg-card"> {{ y }}
{{ y }} </option>
</option> </select>
</select> </div>
<!-- Right: nav + today + create -->
<div class="flex items-center gap-2">
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Previous month" @click="goPrev">
<ChevronLeft class="h-4 w-4" />
</button>
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Next month" @click="goNext">
<ChevronRight class="h-4 w-4" />
</button>
<button class="cursor-pointer ml-1 rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40"
@click="goToday">
Today
</button>
<button
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" />
Create
</button>
</div>
</div> </div>
<FullCalendar ref="calendarRef" :options="calendarOptions" />
<!-- Right: nav + today + create -->
<div class="flex items-center gap-2">
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Previous month" @click="goPrev">
<ChevronLeft class="h-4 w-4" />
</button>
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Next month" @click="goNext">
<ChevronRight class="h-4 w-4" />
</button>
<button class="cursor-pointer ml-1 rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40"
@click="goToday">
Today
</button>
<button
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" />
Create
</button>
</div>
</div> </div>
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div> </div>
</div> <aside v-if="panelOpen && activeEvent" class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col"
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
<aside v-if="panelOpen" class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col" <!-- Header -->
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }"> <div class="flex items-center justify-between gap-3 border-b px-4 py-3">
<!-- Header --> <h2 class="text-lg font-semibold line-clamp-2">
<div class="flex items-center justify-between gap-3 border-b px-4 py-3"> {{ activeEvent?.name || 'Event' }}
<h2 class="text-lg font-semibold line-clamp-2"> </h2>
{{ activeEvent?.title || 'Event' }} <button
</h2> class="inline-flex size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition"
<button aria-label="Close" @click="panelOpen = false">
class="inline-flex size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition" <X class="size-4" />
aria-label="Close" @click="panelOpen = false"> </button>
<X class="size-4" /> </div>
</button> <!-- Body -->
</div> <div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-6">
<!-- When -->
<!-- Body --> <section v-if="whenText" class="space-y-2">
<div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-6"> <div class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<!-- When --> <Clock class="size-4 opacity-80" />
<section v-if="whenText" class="space-y-2"> <span class="font-medium">{{ whenText }}</span>
<div class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm"> </div>
<Clock class="size-4 opacity-80" /> </section>
<span class="font-medium">{{ whenText }}</span> <!-- Quick meta chips -->
</div> <section class="flex flex-wrap gap-2">
</section> <span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<!-- Quick meta chips --> <MapPin class="size-3.5 opacity-80" />
<section class="flex flex-wrap gap-2"> <span class="font-medium">{{ activeEvent.location || "Unknown" }}</span>
<span v-if="ext.location" </span>
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs"> <span
<MapPin class="size-3.5 opacity-80" /> class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<span class="font-medium">{{ ext.location }}</span> <User class="size-3.5 opacity-80" />
</span> <span class="font-medium">Owner: {{ activeEvent.creator_name || "Unknown User" }}</span>
<span v-if="ext.owner" class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs"> </span>
<User class="size-3.5 opacity-80" /> </section>
<span class="font-medium">Owner: {{ ext.owner }}</span> <!-- Agenda (special-cased array) -->
</span> <!-- <section v-if="Array.isArray(ext.agenda) && ext.agenda.length" class="space-y-2">
<span v-if="ext.trainer" <div class="flex items-center gap-2 text-sm font-medium">
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs"> <ListTodo class="size-4 opacity-80" />
<User class="size-3.5 opacity-80" /> Agenda
<span class="font-medium">Trainer: {{ ext.trainer }}</span> </div>
</span> <ul class="space-y-1.5">
</section> <li v-for="(item, i) in ext.agenda" :key="i" class="flex items-start gap-2 text-sm">
<span class="mt-1.5 size-1.5 rounded-full bg-foreground/50"></span>
<!-- Agenda (special-cased array) --> <span>{{ item }}</span>
<section v-if="Array.isArray(ext.agenda) && ext.agenda.length" class="space-y-2"> </li>
<div class="flex items-center gap-2 text-sm font-medium"> </ul>
<ListTodo class="size-4 opacity-80" /> </section> -->
Agenda <!-- Generic details (extendedProps minus the ones above) -->
</div> <!-- <section v-if="ext && Object.keys(ext).length" class="space-y-3">
<ul class="space-y-1.5"> <div class="text-sm font-medium opacity-80">Details</div>
<li v-for="(item, i) in ext.agenda" :key="i" class="flex items-start gap-2 text-sm"> <dl class="grid grid-cols-1 gap-y-3">
<span class="mt-1.5 size-1.5 rounded-full bg-foreground/50"></span> <template v-for="(val, key) in ext" :key="key">
<span>{{ item }}</span> <template v-if="!['agenda', 'owner', 'trainer', 'location'].includes(String(key))">
</li> <div class="grid grid-cols-[120px_1fr] items-start gap-3">
</ul> <dt class="text-xs uppercase tracking-wide opacity-60">{{ key }}</dt>
</section> <dd class="text-sm">
<span v-if="Array.isArray(val)">{{ val.join(', ') }}</span>
<!-- Generic details (extendedProps minus the ones above) --> <span v-else>{{ String(val) }}</span>
<section v-if="ext && Object.keys(ext).length" class="space-y-3"> </dd>
<div class="text-sm font-medium opacity-80">Details</div> </div>
<dl class="grid grid-cols-1 gap-y-3"> </template>
<template v-for="(val, key) in ext" :key="key">
<template v-if="!['agenda', 'owner', 'trainer', 'location'].includes(String(key))">
<div class="grid grid-cols-[120px_1fr] items-start gap-3">
<dt class="text-xs uppercase tracking-wide opacity-60">{{ key }}</dt>
<dd class="text-sm">
<span v-if="Array.isArray(val)">{{ val.join(', ') }}</span>
<span v-else>{{ String(val) }}</span>
</dd>
</div>
</template> </template>
</template> </dl>
</dl> </section> -->
</section> </div>
</div> <!-- Footer (optional actions) -->
<div class="border-t px-4 py-3 flex items-center justify-end gap-2">
<!-- Footer (optional actions) --> <button class="rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40 transition">
<div class="border-t px-4 py-3 flex items-center justify-end gap-2"> Edit
<button class="rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40 transition"> </button>
Edit <button
</button> class="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm hover:opacity-90 transition">
<button Open details
class="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm hover:opacity-90 transition"> </button>
Open details </div>
</button> </aside>
</div> </div>
</aside>
</div> </div>
</template> </template>