108 Commits

Author SHA1 Message Date
0382acbb25 Tried my damndest, will pick this up again another time 2025-12-01 14:58:45 -05:00
b193785f88 Add ecosystem.config.js
create PM2 ecosystem file so the api node application can be added to bare metal system launcher for autostart and restart on code deploy
2025-11-30 16:35:32 -06:00
f9fabef97e Merge pull request 'Glitchtip-Integration' (#39) from Glitchtip-Integration into main
Reviewed-on: #39
2025-11-29 12:25:00 -06:00
d1bfa035f9 fixed misconfigured sentry 2025-11-29 13:25:49 -05:00
1aac9d8c1a Added sentry and dev environment warning baner 2025-11-29 13:18:24 -05:00
4952ed55ae Merge pull request 'Fixed first time login error' (#38) from First-Time-Login-Fix into main
Reviewed-on: #38
2025-11-28 18:02:33 -06:00
d0252ded44 Fixed first time login error 2025-11-28 19:01:07 -05:00
f2aa28b1a4 Merge pull request 'Update ui/src/components/trainingReport/trainingReportForm.vue' (#36) from training-report-cbox-reset into main
Reviewed-on: #36
2025-11-28 16:00:26 -06:00
c935a9950c sorted possible training options by name 2025-11-28 17:00:07 -05:00
e5806e275f cleaned up date formats a bit 2025-11-28 16:47:42 -05:00
d24a01db8c Integrated new time handling system 2025-11-28 15:31:35 -05:00
f499e33fe1 Merge remote-tracking branch 'Origin/main' into training-report-cbox-reset 2025-11-28 15:15:51 -05:00
bfcd7d4c7a fixed header unbalance 2025-11-28 15:14:56 -05:00
8321b67baf Modified header to address pass/fail confusions 2025-11-28 15:14:23 -05:00
40e097fc71 disabled extra logging 2025-11-28 14:47:18 -05:00
104946b2d1 Fixed checkbox reset not updating visually 2025-11-28 14:41:06 -05:00
54475b529e Merge pull request '20-calendar-system' (#37) from 20-calendar-system into main
Reviewed-on: #37
2025-11-28 00:06:10 -06:00
9ca6b55b03 Merge remote-tracking branch 'Origin/main' into 20-calendar-system 2025-11-28 01:05:22 -05:00
99e66763b0 tweaked dialog scrollbar 2025-11-28 01:01:14 -05:00
521dc70f86 adjusted form error message spacing 2025-11-28 00:54:28 -05:00
3a34a35edb Fixed event description rendering handling whitespace and newline 2025-11-28 00:47:44 -05:00
2a9dc51a5d overhauled color selection system 2025-11-28 00:41:50 -05:00
6d53d3e254 tweaked edit mode titles 2025-11-27 23:12:35 -05:00
f82a750cee Implemented update event systems 2025-11-27 23:10:20 -05:00
9896a9289a improved reactivity of event creation 2025-11-27 20:14:11 -05:00
2f2071bd32 Fixed unknown event creator issue 2025-11-27 20:02:23 -05:00
0ba42e6f78 finalized event cancel logic 2025-11-27 19:53:31 -05:00
33fcb16427 beat calendar styling into submission to support multi day events 2025-11-27 15:19:05 -05:00
0b3a95cdc0 implemented cancelled event visualization 2025-11-27 13:40:58 -05:00
941004f913 Finished first pass of event creation system 2025-11-27 13:15:41 -05:00
e14ad7ad44 fixed an import issue 2025-11-27 13:15:23 -05:00
81716d4a4f hooked up create event 2025-11-27 13:08:33 -05:00
4dc121c018 made events open instantly when navigating to a given link 2025-11-26 09:33:43 -05:00
3f9df22a5d added API build command 2025-11-26 09:26:35 -05:00
de84b0d849 broke up the mega monolith that is the calendar file 2025-11-25 23:26:44 -05:00
2d294b7549 Fixed attendance button outlines 2025-11-25 22:45:05 -05:00
f4fae1f84c Modified Checkbox Updates on Course re-select 2025-11-25 21:23:13 -06:00
560a82cc09 Added live update after attendance set 2025-11-25 22:04:33 -05:00
1a714289ee Adapted calendar to support event links 2025-11-25 21:41:01 -05:00
145479adfe added my current attendance state to buttons 2025-11-25 20:30:51 -05:00
ca4f6a811f Integrated attendance system 2025-11-25 13:11:08 -05:00
121dd44a78 Merge pull request 'Fixed hardcoded nonsense in api config' (#31) from API-config-fix into main
Reviewed-on: #31
2025-11-24 22:47:05 -06:00
0d1788500b Update ui/src/components/trainingReport/trainingReportForm.vue
Added a Watcher Code to clear checkboxes when a different training report is picked.
2025-11-24 22:35:47 -06:00
0d9e7c3e3b Fixed hardcoded nonsense in api config 2025-11-23 23:39:10 -05:00
0a718d36c2 split event view into seperate component 2025-11-23 23:12:58 -05:00
658980d9fe fixed text handling on excessively long titles 2025-11-23 18:43:38 -05:00
531371d059 Hooked up calendar viewing to API, still needs a lot more polish 2025-11-23 17:00:47 -05:00
b8bf809c14 Merge pull request '26-login-route' (#28) from 26-login-route into main
Reviewed-on: #28
2025-11-22 17:08:38 -06:00
31d602dbab fixed bad login redirect 2025-11-22 18:02:39 -05:00
836f19e4c7 fixed duplicate URL thing 2025-11-22 18:02:25 -05:00
eabd2da07e added env example comment 2025-11-22 15:45:24 -05:00
0e4725d33c corrected old env names and fixed logout redirect 2025-11-22 15:41:22 -05:00
ac3792c72b Merge pull request 'Training-Report' (#27) from Training-Report into main
Reviewed-on: #27
2025-11-22 14:20:50 -06:00
2e3960a93a Merge branch 'main' into Training-Report 2025-11-22 13:08:30 -05:00
712941458a added searching and sorting system 2025-11-22 11:32:51 -05:00
9f2948ac18 fixed scrolling behaviour 2025-11-21 12:15:22 -05:00
7528a20568 fixed checkbox states getting stuck when switching between views 2025-11-21 12:07:54 -05:00
c72e849b24 refactored to make training reports linkable 2025-11-21 11:57:23 -05:00
856f34f0fa added support for short course names in the case of super long names (looking at you RSLC) 2025-11-21 11:25:50 -05:00
1dcffef2c2 reenabled submitting form 2025-11-21 11:18:26 -05:00
2a327f0d41 fixed inverted checkbox states 2025-11-21 11:16:54 -05:00
a4cd982d3e major style overhaul to report view 2025-11-21 10:51:02 -05:00
938d489f7d minor alignment tweak 2025-11-20 19:40:36 -05:00
9eb815cde5 Major style pass to the form 2025-11-20 19:39:29 -05:00
03a8eee409 added proper error messages 2025-11-20 19:27:09 -05:00
a5461359b7 Implemented tooltips for disabled inputs 2025-11-20 17:39:46 -05:00
9322affce5 adjusted grid styling 2025-11-20 15:11:14 -05:00
91b915fbcf fixed schema validation to support multi checkbox 2025-11-20 14:55:43 -05:00
d9e4c1d6ff added support for optional checkboxes 2025-11-20 14:27:34 -05:00
23ebbe7a85 update report list on submit 2025-11-20 11:08:57 -05:00
aaec72af7e Made all created by human readable 2025-11-20 10:06:01 -05:00
105b28d9a4 Integrated "created by" system 2025-11-20 09:22:59 -05:00
7a31c77c7e applied scroll behaviour to the form too 2025-11-20 09:07:31 -05:00
a075162502 tweaked training report scroll behaviour 2025-11-20 09:06:16 -05:00
aad87096b5 added redirect to completed form on form submission 2025-11-20 00:38:50 -05:00
3560268640 corrected trainer role display 2025-11-20 00:37:56 -05:00
9d14b767a1 FINALLY FIXED THE FUCKING CHECKBOX OH MY GOD 2025-11-19 22:39:08 -05:00
93440eab95 fixed reserved env name 2025-11-19 21:37:16 -05:00
2d28582962 Revert "Updated client env references"
This reverts commit ca5066249f.
2025-11-19 21:32:12 -05:00
ca5066249f Updated client env references 2025-11-19 21:21:49 -05:00
a1a5654f63 fixed leftover hardcoded API logic 2025-11-19 21:13:33 -05:00
0a67d0c82b Merge pull request 'hardcode-fix' (#25) from hardcode-fix into main
Reviewed-on: #25

close #24
2025-11-19 19:58:37 -06:00
eb91f678a8 whoops wrong env 2025-11-19 20:52:47 -05:00
8845024f76 removed hardcoded auth url from client 2025-11-19 20:50:41 -05:00
0da44cbd34 Removed logging 2025-11-19 15:37:17 -05:00
aacb499971 fixed checbox not reporting correct value 2025-11-19 14:44:16 -05:00
5adbfa520c Updated BASE_URL and AUTH_DOMAIN in env example and pages 2025-11-19 13:36:15 -06:00
7850767967 hooked up UI to API 2025-11-19 13:58:37 -05:00
403a8b394c added attendees to form 2025-11-19 12:30:33 -05:00
76ec0179b9 First pass of training report form, lacks attendees 2025-11-18 19:38:24 -05:00
995d145384 corrected zod version mismatch 2025-11-17 19:37:55 -05:00
28d4607768 started training report form 2025-11-17 19:28:09 -05:00
cbefff34f5 Revert "more typescript changes/conversion nonsense (this broke a lot of stuff)"
This reverts commit 74151dbf2d.
2025-11-17 17:16:37 -05:00
74151dbf2d more typescript changes/conversion nonsense (this broke a lot of stuff) 2025-11-17 16:00:20 -05:00
881df1c2df small spacing fix 2025-11-17 11:59:57 -05:00
2eeb62cf3c added member names to training reports 2025-11-17 11:57:25 -05:00
750ee5f02c removed nuisance print 2025-11-17 11:57:03 -05:00
1df4893c67 implemented most of the viewing training reports UI 2025-11-17 00:21:38 -05:00
1d35fe1cf5 added support for course name in course_event details 2025-11-16 23:40:06 -05:00
5387306d93 removed nuisance logging 2025-11-16 22:55:12 -05:00
631eae4412 added training report list to client 2025-11-16 22:51:42 -05:00
f49988fbaf added zod dep to shared library 2025-11-16 22:50:16 -05:00
dd07397c2d switched vue project to proper tsconfig 2025-11-16 22:49:59 -05:00
f6dd3a77dc added support for short format training report and created_by field 2025-11-16 22:37:41 -05:00
4d0dea553e added support for getting all training reports 2025-11-16 10:15:52 -05:00
810a15d279 added API support for posting training reports 2025-11-16 10:10:09 -05:00
0ff3fc58de implemented getter for course event details 2025-11-16 01:29:22 -05:00
ca152f7955 added service with base function to get course and event attendees 2025-11-16 00:48:30 -05:00
70 changed files with 4524 additions and 475 deletions

25
api/.env.example Normal file
View File

@@ -0,0 +1,25 @@
# DATABASE SETTINGS
DB_HOST=
DB_PORT=
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=
# AUTH SETTINGS
AUTH_DOMAIN=
AUTH_ISSUER=
AUTH_CLIENT_ID=
AUTH_CLIENT_SECRET=
AUTH_REDIRECT_URI=
AUTH_REVOCATION_URI=
AUTH_END_SESSION_URI=
# AUTH_MODE=mock #uncomment this to bypass authentik
# SERVER SETTINGS
SERVER_PORT=3000
CLIENT_URL= # This is whatever URL the client web app is served on
CLIENT_DOMAIN= #whatever.com
# Glitchtip
GLITCHTIP_DSN=
DISABLE_GLITCHTIP= # true/false

1441
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,11 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"dev": "tsc && node ./built/api/src/index.js" "dev": "tsc && tsc-alias && node ./built/api/src/index.js",
"build": "tsc && tsc-alias"
}, },
"dependencies": { "dependencies": {
"@sentry/node": "^10.27.0",
"connect-sqlite3": "^0.9.16", "connect-sqlite3": "^0.9.16",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
@@ -26,6 +28,7 @@
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/morgan": "^1.9.10", "@types/morgan": "^1.9.10",
"@types/node": "^24.8.1", "@types/node": "^24.8.1",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -8,7 +8,7 @@ const app = express()
app.use(morgan('dev')) app.use(morgan('dev'))
app.use(cors({ app.use(cors({
origin: ['https://aj17thdev.nexuszone.net', 'http://localhost:5173'], // your SPA origins origin: [process.env.CLIENT_URL], // your SPA origins
credentials: true credentials: true
})); }));
@@ -18,6 +18,16 @@ app.set('trust proxy', 1);
const port = process.env.SERVER_PORT; const port = process.env.SERVER_PORT;
//glitchtip setup
const sentry = require('@sentry/node');
if (!process.env.DISABLE_GLITCHTIP) {
console.log("Glitchtip disabled AAAAAA")
} else {
let dsn = process.env.GLITCHTIP_DSN;
sentry.init({ dsn: dsn });
console.log("Glitchtip initialized");
}
//session setup //session setup
const path = require('path') const path = require('path')
const session = require('express-session') const session = require('express-session')
@@ -32,7 +42,7 @@ app.use(session({
cookie: { cookie: {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
domain: 'nexuszone.net' domain: process.env.CLIENT_DOMAIN
} }
})); }));
app.use(passport.authenticate('session')); app.use(passport.authenticate('session'));
@@ -45,6 +55,8 @@ const loaHandler = require('./routes/loa')
const { status, memberStatus } = require('./routes/statuses') 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 { calendarRouter } = require('./routes/calendar')
const morgan = require('morgan'); const morgan = require('morgan');
app.use('/application', applicationsRouter); app.use('/application', applicationsRouter);
@@ -56,6 +68,9 @@ app.use('/status', status)
app.use('/memberStatus', memberStatus) app.use('/memberStatus', memberStatus)
app.use('/roles', roles) app.use('/roles', roles)
app.use('/memberRoles', memberRoles) app.use('/memberRoles', memberRoles)
app.use('/course', courseRouter)
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

@@ -12,9 +12,9 @@ const querystring = require('querystring');
passport.use(new OpenIDConnectStrategy({ passport.use(new OpenIDConnectStrategy({
issuer: process.env.AUTH_ISSUER, issuer: process.env.AUTH_ISSUER,
authorizationURL: 'https://sso.iceberg-gaming.com/application/o/authorize/', authorizationURL: process.env.AUTH_DOMAIN + '/authorize/',
tokenURL: 'https://sso.iceberg-gaming.com/application/o/token/', tokenURL: process.env.AUTH_DOMAIN + '/token/',
userInfoURL: 'https://sso.iceberg-gaming.com/application/o/userinfo/', userInfoURL: process.env.AUTH_DOMAIN + '/userinfo/',
clientID: process.env.AUTH_CLIENT_ID, clientID: process.env.AUTH_CLIENT_ID,
clientSecret: process.env.AUTH_CLIENT_SECRET, clientSecret: process.env.AUTH_CLIENT_SECRET,
callbackURL: process.env.AUTH_REDIRECT_URI, callbackURL: process.env.AUTH_REDIRECT_URI,
@@ -46,9 +46,8 @@ passport.use(new OpenIDConnectStrategy({
`INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`, `INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`,
[username, sub, issuer] [username, sub, issuer]
) )
memberId = result.insertId; memberId = Number(result.insertId);
} }
await con.commit(); await con.commit();
return cb(null, { memberId }); return cb(null, { memberId });
} catch (error) { } catch (error) {
@@ -61,7 +60,7 @@ passport.use(new OpenIDConnectStrategy({
router.get('/login', (req, res, next) => { router.get('/login', (req, res, next) => {
// Store redirect target in session if provided // Store redirect target in session if provided
req.session.redirectTo = req.query.redirect || '/'; req.session.redirectTo = req.query.redirect;
next(); next();
}, passport.authenticate('openidconnect')); }, passport.authenticate('openidconnect'));
@@ -69,7 +68,7 @@ router.get('/login', (req, res, next) => {
// router.get('/callback', (req, res, next) => { // router.get('/callback', (req, res, next) => {
// passport.authenticate('openidconnect', { // passport.authenticate('openidconnect', {
// successRedirect: req.session.redirectTo, // successRedirect: req.session.redirectTo,
// failureRedirect: 'https://aj17thdev.nexuszone.net/' // failureRedirect: process.env.CLIENT_URL
// }) // })
// }); // });
@@ -77,27 +76,27 @@ router.get('/callback', (req, res, next) => {
const redirectURI = req.session.redirectTo; const redirectURI = req.session.redirectTo;
passport.authenticate('openidconnect', (err, user) => { passport.authenticate('openidconnect', (err, user) => {
if (err) return next(err); if (err) return next(err);
if (!user) return res.redirect('https://aj17thdev.nexuszone.net/'); if (!user) return res.redirect(process.env.CLIENT_URL);
req.logIn(user, err => { req.logIn(user, err => {
if (err) return next(err); if (err) return next(err);
// Use redirect saved from session // Use redirect saved from session
const redirectTo = redirectURI || 'https://aj17thdev.nexuszone.net/'; const redirectTo = redirectURI || process.env.CLIENT_URL;
delete req.session.redirectTo; delete req.session.redirectTo;
return res.redirect(redirectTo); return res.redirect(redirectTo);
}); });
})(req, res, next); })(req, res, next);
}); });
router.post('/logout', function (req, res, next) { router.get('/logout', function (req, res, next) {
req.logout(function (err) { req.logout(function (err) {
if (err) { return next(err); } if (err) { return next(err); }
var params = { var params = {
client_id: process.env.AUTH_CLIENT_ID, client_id: process.env.AUTH_CLIENT_ID,
returnTo: 'https://aj17thdev.nexuszone.net/' returnTo: process.env.CLIENT_URL
}; };
res.redirect(process.env.AUTH_DOMAIN + '/v2/logout?' + querystring.stringify(params)); res.redirect(process.env.AUTH_END_SESSION_URI + '?' + querystring.stringify(params));
}); });
}); });
@@ -109,6 +108,7 @@ passport.serializeUser(function (user, cb) {
passport.deserializeUser(function (user, cb) { passport.deserializeUser(function (user, cb) {
process.nextTick(async function () { process.nextTick(async function () {
const memberID = user.memberId; const memberID = user.memberId;
const con = await pool.getConnection(); const con = await pool.getConnection();

View File

@@ -1,4 +1,6 @@
import { getEventAttendance, getEventDetails, getShortEventsInRange } from "../services/calendarService"; import { Request, Response } from "express";
import { createEvent, getEventAttendance, getEventDetails, getShortEventsInRange, setAttendanceStatus, setEventCancelled, updateEvent } from "../services/calendarService";
import { CalendarAttendance, CalendarEvent } from "@app/shared/types/calendar";
const express = require('express'); const express = require('express');
const r = express.Router(); const r = express.Router();
@@ -9,42 +11,108 @@ 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;
}
const events = await getShortEventsInRange(fromDate, toDate);
res.status(200).json(events); 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) => {
res.sendStatus(501); res.sendStatus(501);
}) })
//get event details r.post('/:id/cancel', async (req: Request, res: Response) => {
r.get('/:id', async (req, res) => {
try { try {
const eventID: number = req.params.id; const eventID = Number(req.params.id);
setEventCancelled(eventID, true);
res.sendStatus(200);
} catch (error) {
console.error('Error setting cancel status:', error);
res.status(500).send('Error setting cancel status');
}
})
r.post('/:id/uncancel', async (req: Request, res: Response) => {
try {
const eventID = Number(req.params.id);
setEventCancelled(eventID, false);
res.sendStatus(200);
} catch (error) {
console.error('Error setting cancel status:', error);
res.status(500).send('Error setting cancel status');
}
})
let details = getEventDetails(eventID);
let attendance = await getEventAttendance(eventID);
let out = { ...details, attendance } r.post('/:id/attendance', async (req: Request, res: Response) => {
console.log(out); try {
res.status(200).json(out); let member = req.user.id;
let event = Number(req.params.id);
let state = req.query.state as CalendarAttendance;
setAttendanceStatus(member, event, state);
res.sendStatus(200);
} catch (error) {
console.error('Failed to set attendance:', error);
res.status(500).json(error);
}
})
//get event details
r.get('/:id', async (req: Request, res: Response) => {
try {
const eventID: number = Number(req.params.id);
let details: CalendarEvent = await getEventDetails(eventID);
details.eventSignups = await getEventAttendance(eventID);
res.status(200).json(details);
} catch (err) { } catch (err) {
console.error('Insert failed:', err); console.error('Insert failed:', err);
res.status(500).json(err); res.status(500).json(err);
} }
}) })
//post a new calendar event
r.post('/', async (req, res) => {
//post a new calendar event
r.post('/', async (req: Request, res: Response) => {
try {
const member = req.user.id;
let event: CalendarEvent = req.body;
event.creator_id = member;
event.start = new Date(event.start);
event.end = new Date(event.end);
createEvent(event);
res.sendStatus(200);
} catch (error) {
console.error('Failed to create event:', error);
res.status(500).json(error);
}
}) })
module.exports.calendar = r; r.put('/', async (req: Request, res: Response) => {
try {
let event: CalendarEvent = req.body;
event.start = new Date(event.start);
event.end = new Date(event.end);
console.log(event);
updateEvent(event);
res.sendStatus(200);
} catch (error) {
console.error('Failed to update event:', error);
res.status(500).json(error);
}
})
module.exports.calendarRouter = r;

89
api/src/routes/course.ts Normal file
View File

@@ -0,0 +1,89 @@
import { CourseAttendee, CourseEventDetails } from "@app/shared/types/course";
import { getAllCourses, getCourseEventAttendees, getCourseEventDetails, getCourseEventRoles, getCourseEvents, insertCourseEvent } from "../services/CourseSerivce";
import { Request, Response, Router } from "express";
const courseRouter = Router();
const eventRouter = Router();
courseRouter.get('/', async (req, res) => {
try {
const courses = await getAllCourses();
res.status(200).json(courses);
} catch (err) {
console.error('failed to fetch courses', err);
res.status(500).json('failed to fetch courses\n' + err);
}
})
courseRouter.get('/roles', async (req, res) => {
try {
const roles = await getCourseEventRoles();
res.status(200).json(roles);
} catch (err) {
console.error('failed to fetch course roles', err);
res.status(500).json('failed to fetch course roles\n' + err);
}
})
eventRouter.get('/', async (req: Request, res: Response) => {
const allowedSorts = new Map([
["ascending", "ASC"],
["descending", "DESC"]
]);
const sort = String(req.query.sort || "").toLowerCase();
const search = String(req.query.search || "").toLowerCase();
if (!allowedSorts.has(sort)) {
return res.status(400).json({
message: `Invalid sort direction '${req.query.sort}'. Allowed values are 'ascending' or 'descending'.`
});
}
const sortDir = allowedSorts.get(sort);
try {
let events = await getCourseEvents(sortDir, search);
res.status(200).json(events);
} catch (error) {
console.error('failed to fetch reports', error);
res.status(500).json(error);
}
});
eventRouter.get('/:id', async (req: Request, res: Response) => {
try {
let out = await getCourseEventDetails(Number(req.params.id));
res.status(200).json(out);
} catch (error) {
console.error('failed to fetch report', error);
res.status(500).json(error);
}
});
eventRouter.get('/attendees/:id', async (req: Request, res: Response) => {
try {
const attendees: CourseAttendee[] = await getCourseEventAttendees(Number(req.params.id));
res.status(200).json(attendees);
} catch (err) {
console.error('failed to fetch attendees', err);
res.status(500).json("failed to fetch attendees\n" + err);
}
})
eventRouter.post('/', async (req: Request, res: Response) => {
const posterID: number = req.user.id;
try {
console.log();
let data: CourseEventDetails = req.body;
data.created_by = posterID;
data.event_date = new Date(data.event_date);
const id = await insertCourseEvent(data);
res.status(201).json(id);
} catch (error) {
console.error('failed to post training', error);
res.status(500).json("failed to post training\n" + error)
}
})
module.exports.courseRouter = courseRouter;
module.exports.eventRouter = eventRouter;

View File

@@ -0,0 +1,147 @@
import pool from "../db"
import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails, CourseEventSummary, RawAttendeeRow } from "@app/shared/types/course"
import { toDateTime } from "@app/shared/utils/time";
export async function getAllCourses(): Promise<Course[]> {
const sql = "SELECT * FROM courses WHERE deleted = false ORDER BY name ASC;"
const res: Course[] = await pool.query(sql);
return res;
}
export async function getCourseByID(id: number): Promise<Course> {
const sql = "SELECT * FROM courses WHERE id = ?;"
const res: Course[] = await pool.query(sql, [id]);
return res[0];
}
function buildAttendee(row: RawAttendeeRow): CourseAttendee {
return {
passed_bookwork: !!row.passed_bookwork,
passed_qual: !!row.passed_qual,
attendee_id: row.attendee_id,
course_event_id: row.course_event_id,
created_at: new Date(row.created_at),
updated_at: new Date(row.updated_at),
remarks: row.remarks,
attendee_role_id: row.attendee_role_id,
attendee_name: row.attendee_name,
role: row.role_id
? {
id: row.role_id,
name: row.role_name,
description: row.role_description,
deleted: !!row.role_deleted,
created_at: new Date(row.role_created_at),
updated_at: new Date(row.role_updated_at),
}
: null
};
}
export async function getCourseEventAttendees(id: number): Promise<CourseAttendee[]> {
const sql = `SELECT
ca.*,
mem.name AS attendee_name,
ar.id AS role_id,
ar.name AS role_name,
ar.description AS role_description,
ar.deleted AS role_deleted,
ar.created_at AS role_created_at,
ar.updated_at AS role_updated_at
FROM course_attendees ca
LEFT JOIN course_attendee_roles ar ON ar.id = ca.attendee_role_id
LEFT JOIN members mem ON ca.attendee_id = mem.id
WHERE ca.course_event_id = ?;`;
const res: RawAttendeeRow[] = await pool.query(sql, [id]);
return res.map((row) => buildAttendee(row))
}
export async function getCourseEventDetails(id: number): Promise<CourseEventDetails> {
const sql = `SELECT
E.*,
M.name AS created_by_name,
C.name AS course_name
FROM course_events AS E
LEFT JOIN courses AS C
ON E.course_id = C.id
LEFT JOIN members AS M
ON E.created_by = M.id
WHERE E.id = ?;
`;
let rows: CourseEventDetails[] = await pool.query(sql, [id]);
let event = rows[0];
event.attendees = await getCourseEventAttendees(id);
event.course = await getCourseByID(event.course_id);
return event;
}
export async function insertCourseEvent(event: CourseEventDetails): Promise<number> {
console.log(event);
const con = await pool.getConnection();
try {
await con.beginTransaction();
const res = await con.query("INSERT INTO course_events (course_id, event_date, remarks, created_by) VALUES (?, ?, ?, ?);", [event.course_id, toDateTime(event.event_date), event.remarks, event.created_by]);
var eventID: number = res.insertId;
for (const attendee of event.attendees) {
await con.query(`INSERT INTO course_attendees (
attendee_id,
course_event_id,
attendee_role_id,
passed_bookwork,
passed_qual,
remarks
)
VALUES (?, ?, ?, ?, ?, ?);`, [attendee.attendee_id, eventID, attendee.attendee_role_id, attendee.passed_bookwork, attendee.passed_qual, attendee.remarks]);
}
await con.commit();
await con.release();
return Number(eventID);
} catch (error) {
await con.rollback();
await con.release();
throw error;
}
}
export async function getCourseEvents(sortDir: string, search: string = ""): Promise<CourseEventSummary[]> {
let params = [];
let searchString = "";
if (search !== "") {
searchString = `WHERE (C.name LIKE ? OR
C.short_name LIKE ? OR
M.name LIKE ?) `;
const p = `%${search}%`;
params.push(p, p, p);
}
const sql = `SELECT
E.id AS event_id,
E.course_id,
E.event_date AS date,
E.created_by,
C.name AS course_name,
C.short_name AS course_shortname,
M.name AS created_by_name
FROM course_events AS E
LEFT JOIN courses AS C
ON E.course_id = C.id
LEFT JOIN members AS M
ON E.created_by = M.id
${searchString}
ORDER BY E.event_date ${sortDir};`;
console.log(sql)
console.log(params)
let events: CourseEventSummary[] = await pool.query(sql, params);
return events;
}
export async function getCourseEventRoles(): Promise<CourseAttendeeRole[]> {
const sql = "SELECT * FROM course_attendee_roles;"
const roles: CourseAttendeeRole[] = await pool.query(sql);
return roles;
}

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,26 +1,12 @@
import pool from '../db'; import pool from '../db';
import { CalendarEventShort, CalendarSignup, CalendarEvent, CalendarAttendance } from "@app/shared/types/calendar"
export interface CalendarEvent { import { toDateTime } from "@app/shared/utils/time"
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 async function createEvent(eventObject: Omit<CalendarEvent, 'id' | 'created_at' | 'updated_at' | 'cancelled'>) { export async function createEvent(eventObject: Omit<CalendarEvent, 'id' | 'created_at' | 'updated_at' | 'cancelled'>) {
const sql = ` const sql = `
INSERT INTO calendar_events INSERT INTO calendar_events
(name, start, end, location, color, description, creator) (name, start, end, location, color, description, creator)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`; `;
const params = [ const params = [
eventObject.name, eventObject.name,
@@ -29,7 +15,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);
@@ -40,7 +26,6 @@ export async function updateEvent(eventObject: CalendarEvent) {
if (!eventObject.id) { if (!eventObject.id) {
throw new Error("updateEvent: Missing event ID."); throw new Error("updateEvent: Missing event ID.");
} }
const sql = ` const sql = `
UPDATE calendar_events UPDATE calendar_events
SET SET
@@ -49,14 +34,14 @@ export async function updateEvent(eventObject: CalendarEvent) {
end = ?, end = ?,
location = ?, location = ?,
color = ?, color = ?,
description = ?, description = ?
WHERE id = ? WHERE id = ?
`; `;
const params = [ const params = [
eventObject.name, eventObject.name,
eventObject.start, toDateTime(eventObject.start),
eventObject.end, toDateTime(eventObject.end),
eventObject.location, eventObject.location,
eventObject.color, eventObject.color,
eventObject.description ?? null, eventObject.description ?? null,
@@ -67,28 +52,30 @@ export async function updateEvent(eventObject: CalendarEvent) {
return { success: true }; return { success: true };
} }
export async function cancelEvent(eventID: number) { export async function setEventCancelled(eventID: number, cancelled: boolean) {
const input = cancelled ? 1 : 0;
const sql = ` const sql = `
UPDATE calendar_events UPDATE calendar_events
SET cancelled = 1 SET cancelled = ?
WHERE id = ? WHERE id = ?
`; `;
await pool.query(sql, [eventID]); await pool.query(sql, [input, eventID]);
return { success: true }; return { success: true };
} }
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, cancelled, full_day
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]);
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 +88,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) {
@@ -124,7 +111,7 @@ export async function getUpcomingEvents(date: Date, limit: number) {
} }
export async function setAttendanceStatus(memberID: number, eventID: number, status: Attendance) { export async function setAttendanceStatus(memberID: number, eventID: number, status: CalendarAttendance) {
const sql = ` const sql = `
INSERT INTO calendar_events_signups (member_id, event_id, status) INSERT INTO calendar_events_signups (member_id, event_id, status)
VALUES (?, ?, ?) VALUES (?, ?, ?)
@@ -135,7 +122,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,

View File

@@ -21,3 +21,14 @@ export async function setUserState(userID: number, state: MemberState) {
WHERE id = ?;`; WHERE id = ?;`;
return await pool.query(sql, [state, userID]); return await pool.query(sql, [state, userID]);
} }
declare global {
namespace Express {
interface Request {
user: {
id: number;
name: string;
};
}
}
}

14
ecosystem.config.js Normal file
View File

@@ -0,0 +1,14 @@
module.exports = {
apps : [{
name: '17th-api',
cwd: 'api',
script: 'built/api/src/index.js',
watch: ['.env', 'built'],
ignore_watch: ['.gitignore', '\.json', 'src', '\.db', 'node_modules'],
watch_options: {
usePolling: true,
interval: 10000
},
time: true
}]
};

24
shared/package-lock.json generated Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "@app/shared",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@app/shared",
"version": "1.0.0",
"dependencies": {
"zod": "^3.25.76"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -2,5 +2,8 @@
"name": "@app/shared", "name": "@app/shared",
"version": "1.0.0", "version": "1.0.0",
"main": "index.ts", "main": "index.ts",
"type": "module" "type": "module",
"dependencies": {
"zod": "^3.25.76"
}
} }

View File

@@ -0,0 +1,33 @@
import z from "zod";
const dateRe = /^\d{4}-\d{2}-\d{2}$/ // YYYY-MM-DD
const timeRe = /^(?:[01]\d|2[0-3]):[0-5]\d$/ // HH:mm (24h)\
export function parseLocalDateTime(dateStr: string, timeStr: string) {
// Construct a Date in the user's local timezone
const [y, m, d] = dateStr.split("-").map(Number)
const [hh, mm] = timeStr.split(":").map(Number)
return new Date(y, m - 1, d, hh, mm, 0, 0)
}
export const calendarEventSchema = z.object({
name: z.string().min(2, "Please enter at least 2 characters").max(100),
startDate: z.string().regex(dateRe, "Use YYYY-MM-DD"),
startTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
endDate: z.string().regex(dateRe, "Use YYYY-MM-DD"),
endTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
location: z.string().max(200).default(""),
color: z.string().regex(/^#([0-9A-Fa-f]{6})$/, "Use a hex color like #AABBCC"),
description: z.string().max(2000).default(""),
id: z.number().int().nonnegative().nullable().default(null),
}).superRefine((vals, ctx) => {
const start = parseLocalDateTime(vals.startDate, vals.startTime)
const end = parseLocalDateTime(vals.endDate, vals.endTime)
if (!(end > start)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End must be after start",
path: ["endTime"], // attach to a visible field
})
}
})

View File

@@ -0,0 +1,23 @@
import { z } from "zod";
export const courseEventAttendeeSchema = z.object({
attendee_id: z.number({invalid_type_error: "Must select a member"}).int().positive(),
passed_bookwork: z.boolean(),
passed_qual: z.boolean(),
remarks: z.string(),
attendee_role_id: z.number({invalid_type_error: "Must select a role"}).int().positive()
})
export const trainingReportSchema = z.object({
id: z.number().int().positive().optional(),
course_id: z.number({ invalid_type_error: "Must select a training" }).int(),
event_date: z
.string()
.refine(
(val) => !isNaN(Date.parse(val)),
"Must be a valid date"
),
remarks: z.string().nullable().optional(),
attendees: z.array(courseEventAttendeeSchema).default([]),
})

17
shared/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true
},
"include": ["./**/*.ts"]
}

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

@@ -0,0 +1,39 @@
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 {
member_id: number;
eventID: number;
status: CalendarAttendance;
member_name?: string;
}
export interface CalendarEventShort {
id: number;
name: string;
start: Date;
end: Date;
color: string;
cancelled: boolean;
full_day: boolean;
}

91
shared/types/course.ts Normal file
View File

@@ -0,0 +1,91 @@
export interface Course {
id: number;
name: string;
short_name: string;
category: string;
description?: string | null;
image_url?: string | null;
created_at: Date;
updated_at: Date;
deleted?: number | boolean;
prereq_id?: number | null;
hasBookwork: boolean;
hasQual: boolean;
}
export interface CourseEventDetails {
id: number | null; // PK
course_id: number | null; // FK → courses.id
event_type: number | null; // FK → event_types.id
event_date: Date; // datetime (not nullable)
guilded_event_id: number | null;
created_at: Date; // datetime
updated_at: Date; // datetime
deleted: boolean | null; // tinyint(4), nullable
report_url: string | null; // varchar(2048)
remarks: string | null; // text
attendees: CourseAttendee[] | null;
created_by: number | null;
created_by_name: string | null;
course_name: string | null;
course: Course | null;
}
export interface CourseAttendee {
passed_bookwork: boolean; // tinyint(1)
passed_qual: boolean; // tinyint(1)
attendee_id: number; // PK
course_event_id: number; // PK
attendee_role_id: number | null;
role: CourseAttendeeRole | null;
created_at: Date; // datetime → ISO string
updated_at: Date; // datetime → ISO string
remarks: string | null;
attendee_name: string | null;
}
export interface CourseAttendeeRole {
id: number; // PK, auto-increment
name: string | null; // varchar(50), unique, nullable
description: string | null; // text
created_at: Date | null; // datetime (nullable)
updated_at: Date | null; // datetime (nullable)
deleted: boolean; // tinyint(4)
}
export interface RawAttendeeRow {
passed_bookwork: number; // tinyint(1)
passed_qual: number; // tinyint(1)
attendee_id: number;
course_event_id: number;
attendee_role_id: number | null;
created_at: string;
updated_at: string;
remarks: string | null;
role_id: number | null;
role_name: string | null;
role_description: string | null;
role_deleted: number | null;
role_created_at: string | null;
role_updated_at: string | null;
attendee_name: string | null;
}
export interface CourseEventSummary {
event_id: number;
course_id: number;
course_name: string;
course_shortname: string;
date: string;
created_by: number;
created_by_name: string;
}

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

@@ -0,0 +1,12 @@
export function toDateTime(date: Date): string {
console.log(date);
// 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}`;
}

10
ui/.env.example Normal file
View File

@@ -0,0 +1,10 @@
# SITE SETTINGS
VITE_APIHOST=
VITE_ENVIRONMENT= # dev / prod
# Glitchtip
VITE_GLITCHTIP_DSN=
VITE_DISABLE_GLITCHTIP= # true/false
# Matomo
VITE_DISABLE_MATOMO= # true/false

133
ui/package-lock.json generated
View File

@@ -8,11 +8,13 @@
"name": "milsimsitev4", "name": "milsimsitev4",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@certible/use-matomo": "^1.1.0",
"@fullcalendar/core": "^6.1.19", "@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19", "@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19", "@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19",
"@fullcalendar/vue3": "^6.1.19", "@fullcalendar/vue3": "^6.1.19",
"@sentry/vue": "^10.27.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
@@ -21,7 +23,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.539.0", "lucide-vue-next": "^0.539.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"reka-ui": "^2.5.0", "reka-ui": "^2.6.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",
@@ -509,6 +511,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@certible/use-matomo": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@certible/use-matomo/-/use-matomo-1.1.0.tgz",
"integrity": "sha512-9kOTj0ldP+RX2Rhlosxm/+1uQ2deDefMb4p2DQGFi3Gqi3TuTSrOtx+beUNKGN7D8up/8SsBPJS+ipY1ZHMfrA==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.9", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
@@ -1392,6 +1400,103 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sentry-internal/browser-utils": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.27.0.tgz",
"integrity": "sha512-17tO6AXP+rmVQtLJ3ROQJF2UlFmvMWp7/8RDT5x9VM0w0tY31z8Twc0gw2KA7tcDxa5AaHDUbf9heOf+R6G6ow==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.27.0.tgz",
"integrity": "sha512-UecsIDJcv7VBwycge/MDvgSRxzevDdcItE1i0KSwlPz00rVVxLY9kV28PJ4I2E7r6/cIaP9BkbWegCEcv09NuA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.27.0.tgz",
"integrity": "sha512-tKSzHq1hNzB619Ssrqo25cqdQJ84R3xSSLsUWEnkGO/wcXJvpZy94gwdoS+KmH18BB1iRRRGtnMxZcUkiPSesw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.27.0",
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.27.0.tgz",
"integrity": "sha512-inhsRYSVBpu3BI1kZphXj6uB59baJpYdyHeIPCiTfdFNBE5tngNH0HS/aedZ1g9zICw290lwvpuyrWJqp4VBng==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.27.0",
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.27.0.tgz",
"integrity": "sha512-G8q362DdKp9y1b5qkQEmhTFzyWTOVB0ps1rflok0N6bVA75IEmSDX1pqJsNuY3qy14VsVHYVwQBJQsNltQLS0g==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.27.0",
"@sentry-internal/feedback": "10.27.0",
"@sentry-internal/replay": "10.27.0",
"@sentry-internal/replay-canvas": "10.27.0",
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.27.0.tgz",
"integrity": "sha512-Zc68kdH7tWTDtDbV1zWIbo3Jv0fHAU2NsF5aD2qamypKgfSIMSbWVxd22qZyDBkaX8gWIPm/0Sgx6aRXRBXrYQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/vue": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-10.27.0.tgz",
"integrity": "sha512-vQVxnw59jRe5WsdB9ad/WpMPQ93QXE6Y0JEy01xIRcDlQ1pXp5wuxLkKGuTfvjdQzVUGIBLr0CgIqRAmPRymVg==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.27.0",
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"pinia": "2.x || 3.x",
"vue": "2.x || 3.x"
},
"peerDependenciesMeta": {
"pinia": {
"optional": true
}
}
},
"node_modules/@sindresorhus/merge-streams": { "node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@@ -3235,9 +3340,9 @@
} }
}, },
"node_modules/reka-ui": { "node_modules/reka-ui": {
"version": "2.5.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz", "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.0.tgz",
"integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==", "integrity": "sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",
@@ -3496,13 +3601,13 @@
} }
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.14", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.4.4", "fdir": "^6.5.0",
"picomatch": "^4.0.2" "picomatch": "^4.0.3"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -3630,17 +3735,17 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.2", "version": "7.2.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==", "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.6", "fdir": "^6.5.0",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"rollup": "^4.43.0", "rollup": "^4.43.0",
"tinyglobby": "^0.2.14" "tinyglobby": "^0.2.15"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"

View File

@@ -12,11 +12,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@certible/use-matomo": "^1.1.0",
"@fullcalendar/core": "^6.1.19", "@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19", "@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19", "@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19",
"@fullcalendar/vue3": "^6.1.19", "@fullcalendar/vue3": "^6.1.19",
"@sentry/vue": "^10.27.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
@@ -25,7 +27,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.539.0", "lucide-vue-next": "^0.539.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"reka-ui": "^2.5.0", "reka-ui": "^2.6.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",

View File

@@ -24,14 +24,16 @@ const userStore = useUserStore();
// console.log(data); // console.log(data);
// userStore.user = data; // userStore.user = data;
// }); // });
const APIHOST = import.meta.env.VITE_APIHOST;
async function logout() { async function logout() {
await fetch(`${import.meta.env.VITE_APIHOST}/logout`, { // await fetch(`${APIHOST}/logout`, {
method: 'POST', // method: 'GET',
credentials: 'include', // credentials: 'include',
}); // });
userStore.user = null; userStore.user = null;
window.location.href = APIHOST + "/logout";
} }
function formatDate(dateStr) { function formatDate(dateStr) {
@@ -42,6 +44,8 @@ function formatDate(dateStr) {
day: "numeric", day: "numeric",
}); });
} }
const environment = import.meta.env.VITE_ENVIRONMENT;
</script> </script>
<template> <template>
@@ -112,10 +116,15 @@ function formatDate(dateStr) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<a v-else href="https://aj17thdevapi.nexuszone.net/login">Login</a> <a v-else :href="APIHOST + '/login'">Login</a>
</div> </div>
</div> </div>
<Separator></Separator> <Separator></Separator>
<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">
<p>This is a development build of the application. Some features will be unavailable or unstable.</p>
</AlertDescription>
</Alert>
<Alert v-if="userStore.user?.loa?.[0]" class="m-2 mx-auto w-5xl" variant="info"> <Alert v-if="userStore.user?.loa?.[0]" class="m-2 mx-auto w-5xl" variant="info">
<AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto"> <AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto">
<p>You are on LOA until <strong>{{ formatDate(userStore.user?.loa?.[0].end_date) }}</strong></p> <p>You are on LOA until <strong>{{ formatDate(userStore.user?.loa?.[0].end_date) }}</strong></p>

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,22 +21,107 @@ export interface CalendarSignup {
state: CalendarAttendance state: CalendarAttendance
} }
export async function createCalendarEvent(eventData: CalendarEvent) { 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) {
let res = await fetch(`${addr}/calendar`, {
method: "POST",
credentials: "include",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(eventData)
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }
export async function editCalendarEvent(eventData: CalendarEvent) { export async function editCalendarEvent(eventData: CalendarEvent) {
let res = await fetch(`${addr}/calendar`, {
method: "PUT",
credentials: "include",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(eventData)
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }
export async function cancelCalendarEvent(eventID: number) { export async function setCancelCalendarEvent(eventID: number, cancel: boolean) {
let route = cancel ? "cancel" : "uncancel";
console.log(route);
let res = await fetch(`${addr}/calendar/${eventID}/${route}`, {
method: "POST",
credentials: "include"
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
} }
export async function adminCancelCalendarEvent(eventID: number) {
} }
export async function setCalendarEventAttendance(eventID: number, state: CalendarAttendance) { export async function setCalendarEventAttendance(eventID: number, state: CalendarAttendance) {
let res = await fetch(`${addr}/calendar/${eventID}/attendance?state=${state}`, {
method: "POST",
credentials: "include",
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }

View File

@@ -0,0 +1,66 @@
import { Course, CourseAttendeeRole, CourseEventDetails, CourseEventSummary } from '@shared/types/course'
//@ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getTrainingReports(sortMode: string, search: string): Promise<CourseEventSummary[]> {
const res = await fetch(`${addr}/courseEvent?sort=${sortMode}&search=${search}`);
if (res.ok) {
return await res.json() as Promise<CourseEventSummary[]>;
} else {
console.error("Something went wrong");
throw new Error("Failed to load training reports");
}
}
export async function getTrainingReport(id: number): Promise<CourseEventDetails> {
const res = await fetch(`${addr}/courseEvent/${id}`);
if (res.ok) {
return await res.json() as Promise<CourseEventDetails>;
} else {
console.error("Something went wrong");
throw new Error("Failed to load training reports");
}
}
export async function getAllTrainings(): Promise<Course[]> {
const res = await fetch(`${addr}/course`);
if (res.ok) {
return await res.json() as Promise<Course[]>;
} else {
console.error("Something went wrong");
throw new Error("Failed to load training list");
}
}
export async function getAllAttendeeRoles(): Promise<CourseAttendeeRole[]> {
const res = await fetch(`${addr}/course/roles`);
if (res.ok) {
return await res.json() as Promise<CourseAttendeeRole[]>;
} else {
console.error("Something went wrong");
throw new Error("Failed to load attendee roles");
}
}
export async function postTrainingReport(report: CourseEventDetails) {
const res = await fetch(`${addr}/courseEvent`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(report),
credentials: 'include',
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed to post training report: ${res.status} ${errorText}`);
}
return res.json(); // expected to return the inserted report or new ID
}

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { toTypedSchema } from "@vee-validate/zod" import { toTypedSchema } from "@vee-validate/zod"
import { ref, defineExpose, watch } from "vue" import { ref, defineExpose, watch, nextTick } from "vue"
import * as z from "zod" import * as z from "zod"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -21,11 +21,18 @@ import {
} from "@/components/ui/form" } from "@/components/ui/form"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import Textarea from "../ui/textarea/Textarea.vue" import Textarea from "../ui/textarea/Textarea.vue"
import { CalendarEvent } from "@/api/calendar" import { CalendarEvent } from "@shared/types/calendar"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
// ---------- helpers ----------
const dateRe = /^\d{4}-\d{2}-\d{2}$/ // YYYY-MM-DD
const timeRe = /^(?:[01]\d|2[0-3]):[0-5]\d$/ // HH:mm (24h)
function toLocalDateString(d: Date) { function toLocalDateString(d: Date) {
// yyyy-MM-dd with local time zone // yyyy-MM-dd with local time zone
@@ -45,45 +52,50 @@ function roundToNextHour(d = new Date()) {
t.setHours(t.getHours() + 1) t.setHours(t.getHours() + 1)
return t return t
} }
function parseLocalDateTime(dateStr: string, timeStr: string) {
// Construct a Date in the user's local timezone
const [y, m, d] = dateStr.split("-").map(Number)
const [hh, mm] = timeStr.split(":").map(Number)
return new Date(y, m - 1, d, hh, mm, 0, 0)
}
// ---------- schema ---------- import { calendarEventSchema, parseLocalDateTime } from '@shared/schemas/calendarEventSchema'
const zEvent = z.object({ import { createCalendarEvent, editCalendarEvent } from "@/api/calendar"
name: z.string().min(2, "Please enter at least 2 characters").max(100), import DialogDescription from "../ui/dialog/DialogDescription.vue"
startDate: z.string().regex(dateRe, "Use YYYY-MM-DD"), const formSchema = toTypedSchema(calendarEventSchema)
startTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
endDate: z.string().regex(dateRe, "Use YYYY-MM-DD"),
endTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
location: z.string().max(200).default(""),
color: z.string().regex(/^#([0-9A-Fa-f]{6})$/, "Use a hex color like #AABBCC"),
description: z.string().max(2000).default(""),
id: z.number().int().nonnegative().nullable().default(null),
}).superRefine((vals, ctx) => {
const start = parseLocalDateTime(vals.startDate, vals.startTime)
const end = parseLocalDateTime(vals.endDate, vals.endTime)
if (!(end > start)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End must be after start",
path: ["endTime"], // attach to a visible field
})
}
})
const formSchema = toTypedSchema(zEvent)
// ---------- dialog state & defaults ---------- // ---------- dialog state & defaults ----------
const clickedDate = ref<string | null>(null);
const dialogOpen = ref(false) const dialogOpen = ref(false)
function openDialog() { dialogOpen.value = true } const dialogMode = ref<'create' | 'edit'>('create');
const editEvent = ref<CalendarEvent | null>();
function openDialog(dateStr?: string, mode?: 'create' | 'edit', event?: CalendarEvent) {
dialogMode.value = mode ?? 'create';
editEvent.value = event ?? null;
clickedDate.value = dateStr ?? null;
dialogOpen.value = true
initialValues.value = makeInitialValues()
}
defineExpose({ openDialog }) defineExpose({ openDialog })
function makeInitialValues() { function makeInitialValues() {
const start = roundToNextHour()
if (dialogMode.value === 'edit' && editEvent.value) {
const e = editEvent.value;
return {
name: e.name,
startDate: toLocalDateString(new Date(e.start)),
startTime: toLocalTimeString(new Date(e.start)),
endDate: toLocalDateString(new Date(e.end)),
endTime: toLocalTimeString(new Date(e.end)),
location: e.location,
color: e.color,
description: e.description,
id: e.id,
}
}
let start: Date;
if (clickedDate.value) {
const local = new Date(clickedDate.value + "T20:00:00");
start = local;
} else {
start = roundToNextHour();
}
const end = new Date(start.getTime() + 60 * 60 * 1000) const end = new Date(start.getTime() + 60 * 60 * 1000)
return { return {
name: "", name: "",
@@ -92,50 +104,77 @@ function makeInitialValues() {
endDate: toLocalDateString(end), endDate: toLocalDateString(end),
endTime: toLocalTimeString(end), endTime: toLocalTimeString(end),
location: "", location: "",
color: "#3b82f6", color: "#6890ee",
description: "", description: "",
id: null as number | null, id: null as number | null,
} }
} }
const initialValues = ref(makeInitialValues()) const initialValues = ref(null)
const formKey = ref(0) const formKey = ref(0)
watch(dialogOpen, (isOpen) => { watch(dialogOpen, async (isOpen) => {
if (!isOpen) { if (isOpen) {
formKey.value++ // remounts the form -> picks up fresh initialValues await nextTick();
formRef.value?.resetForm({ values: makeInitialValues() })
} }
}) })
// ---------- submit ---------- // ---------- submit ----------
function onSubmit(vals: z.infer<typeof zEvent>) { async function onSubmit(vals: z.infer<typeof calendarEventSchema>) {
const start = parseLocalDateTime(vals.startDate, vals.startTime) const start = parseLocalDateTime(vals.startDate, vals.startTime)
const end = parseLocalDateTime(vals.endDate, vals.endTime) const end = parseLocalDateTime(vals.endDate, vals.endTime)
const event: CalendarEvent = { const event: CalendarEvent = {
id: vals.id ?? null,
name: vals.name, name: vals.name,
start, start,
end, end,
location: vals.location, location: vals.location,
color: vals.color, color: vals.color,
description: vals.description, description: vals.description,
id: null,
creator: null
} }
console.log("Submitting CalendarEvent:", event) try {
if (dialogMode.value === "edit") {
await editCalendarEvent(event);
} else {
await createCalendarEvent(event);
}
emit('reload');
} catch (error) {
console.error(error);
}
// close after success // close after success
dialogOpen.value = false dialogOpen.value = false
} }
const emit = defineEmits<{
(e: 'reload'): void
}>()
const formRef = ref(null);
const colorOptions = [
{ name: "Blue", hex: "#6890ee" },
{ name: "Purple", hex: "#a25fce" },
{ name: "Orange", hex: "#fba037" },
{ name: "Green", hex: "#6cd265" },
{ name: "Red", hex: "#ff5959" },
];
function getColorName(hex: string) {
return colorOptions.find(c => c.hex === hex)?.name ?? hex
}
</script> </script>
<template> <template>
<Form :key="formKey" v-slot="{ handleSubmit, resetForm }" :validation-schema="formSchema" <Form ref="formRef" :key="formKey" v-slot="{ handleSubmit, resetForm }" :validation-schema="formSchema"
:initial-values="initialValues" keep-values as=""> :initial-values="initialValues" keep-values as="">
<Dialog v-model:open="dialogOpen"> <Dialog v-model:open="dialogOpen">
<DialogContent class="sm:max-w-[520px]"> <DialogContent class="sm:max-w-[520px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Create Event</DialogTitle> <DialogTitle>{{ dialogMode == "edit" ? 'Edit Event' : 'Create Event' }}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader> </DialogHeader>
<form id="dialogForm" class="grid grid-cols-1 gap-4" <form id="dialogForm" class="grid grid-cols-1 gap-4"
@@ -150,21 +189,48 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<FormControl> <FormControl>
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
<!-- Color --> <!-- Color -->
<div class="w-[60px]"> <div class="w-[120px]">
<FormField v-slot="{ componentField }" name="color"> <FormField v-slot="{ componentField }" name="color">
<FormItem> <FormItem>
<FormLabel>Color</FormLabel> <FormLabel>Color</FormLabel>
<FormControl> <FormControl>
<Input type="color" class="h-[38px] p-1 cursor-pointer" <Select :modelValue="componentField.modelValue"
v-bind="componentField" /> @update:modelValue="componentField.onChange">
<SelectTrigger>
<SelectValue asChild>
<template #default="{ selected }">
<div class="flex items-center gap-2 w-[70px]">
<span class="inline-block size-4 rounded"
:style="{ background: componentField.modelValue }">
</span>
<span>{{ getColorName(componentField.modelValue) }}</span>
</div>
</template>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in colorOptions" :key="opt.hex" :value="opt.hex">
<div class="flex items-center gap-2">
<span class="inline-block size-4 rounded"
:style="{ background: opt.hex }"></span>
<span>{{ opt.name }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
@@ -180,7 +246,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<FormControl> <FormControl>
<Input type="date" v-bind="componentField" /> <Input type="date" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -191,7 +259,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
<!-- If you ever want native picker: type="time" --> <!-- If you ever want native picker: type="time" -->
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
@@ -204,7 +274,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<FormControl> <FormControl>
<Input type="date" v-bind="componentField" /> <Input type="date" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -214,7 +286,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<FormControl> <FormControl>
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
@@ -226,7 +300,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<FormControl> <FormControl>
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -236,9 +312,11 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<FormControl> <FormControl>
<Textarea class="resize-none h-32" v-bind="componentField" /> <Textarea class="resize-none h-32 scrollbar-themed" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3">
<FormMessage/> <FormMessage/>
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -249,9 +327,39 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
</form> </form>
<DialogFooter> <DialogFooter>
<Button type="submit" form="dialogForm">Create</Button> <Button type="submit" form="dialogForm">{{ dialogMode == "edit" ? 'Update' : 'Create' }}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</Form> </Form>
</template> </template>
<style scoped>
/* Firefox */
.scrollbar-themed {
scrollbar-width: thin;
scrollbar-color: #555 #1f1f1f;
padding-right: 6px;
}
/* Chrome, Edge, Safari */
.scrollbar-themed::-webkit-scrollbar {
width: 10px;
/* slightly wider to allow padding look */
}
.scrollbar-themed::-webkit-scrollbar-track {
background: #1f1f1f;
margin-left: 6px;
/* ❗ adds space between content + scrollbar */
}
.scrollbar-themed::-webkit-scrollbar-thumb {
background: #555;
border-radius: 9999px;
}
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>

View File

@@ -0,0 +1,226 @@
<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 ButtonGroup from '../ui/button-group/ButtonGroup.vue';
import Button from '../ui/button/Button.vue';
import { CalendarAttendance, getCalendarEvent, setCalendarEventAttendance, setCancelCalendarEvent } from '@/api/calendar';
import { useUserStore } from '@/stores/user';
import { useRoute } from 'vue-router';
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';
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) => {
if (!id) return;
activeEvent.value = await getCalendarEvent(Number(id));
loaded.value = true;
},
{ immediate: true }
);
const emit = defineEmits<{
(e: 'close'): void
(e: 'reload'): void
(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 endFmt = 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 attending = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Attending) })
const maybe = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Maybe) })
const declined = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.NotAttending) })
const viewedState = ref<CalendarAttendance>(CalendarAttendance.Attending);
let user = useUserStore();
const myAttendance = computed<CalendarSignup | null>(() => {
return activeEvent.value.eventSignups.find(
(s) => s.member_id === user.user.id
) || null;
});
async function setAttendance(state: CalendarAttendance) {
await setCalendarEventAttendance(activeEvent.value.id, state);
//refresh event data
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
}
const canEditEvent = computed(() => {
if (user.user.id == activeEvent.value.creator_id)
return true;
});
async function setCancel(isCancelled: boolean) {
await setCancelCalendarEvent(activeEvent.value.id, isCancelled);
emit("reload");
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
}
async function forceReload() {
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
}
defineExpose({forceReload})
</script>
<template>
<div v-if="loaded">
<!-- Header -->
<div class="flex items-center justify-between gap-3 border-b px-4 py-3">
<h2 class="text-lg font-semibold break-all">
{{ activeEvent?.name || 'Event' }}
</h2>
<div class="flex gap-4">
<DropdownMenu v-if="canEditEvent">
<DropdownMenuTrigger>
<button
class="inline-flex flex-none size-8 items-center justify-center cursor-pointer rounded-md hover:bg-muted/40 transition">
<EllipsisVertical class="size-6" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="emit('edit', activeEvent)">
Edit
</DropdownMenuItem>
<DropdownMenuItem v-if="activeEvent.cancelled"
@click="setCancel(false)">
Un-Cancel
</DropdownMenuItem>
<DropdownMenuItem v-else @click="setCancel(true)">
Cancel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
class="inline-flex flex-none size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition cursor-pointer"
aria-label="Close" @click="emit('close')">
<X class="size-4" />
</button>
</div>
</div>
<!-- Body -->
<div class="flex-1 flex flex-col items-center min-h-0 overflow-y-auto px-4 py-4 space-y-6 w-full">
<section v-if="activeEvent.cancelled == true" class="w-full">
<div class="flex p-2 rounded-md w-full bg-destructive gap-3">
<CircleAlert></CircleAlert> This event has been cancelled
</div>
</section>
<section class="w-full">
<ButtonGroup class="flex w-full">
<Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.Attending)">Going</Button>
<Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.Maybe ? 'border-2 !border-l-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.Maybe)">Maybe</Button>
<Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.NotAttending ? 'border-2 !border-l-2 border-primary text-primary' : ''"
@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>
</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">
<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">
{{ activeEvent.description }}
</p>
</section>
<!-- Attendance -->
<section class="space-y-2 w-full">
<p class="text-lg font-semibold">Attendance</p>
<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>
</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>
<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">
<p>{{ person.member_name }}</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>

View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
import { trainingReportSchema, courseEventAttendeeSchema } from '@shared/schemas/trainingReportSchema'
import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails } from '@shared/types/course'
import { useForm, useFieldArray, FieldArray as VeeFieldArray, ErrorMessage, Field as VeeField } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { computed, onMounted, ref, watch } from 'vue'
import { getAllAttendeeRoles, getAllTrainings, postTrainingReport } from '@/api/trainingReport'
import { getMembers, Member } from '@/api/member'
import FieldGroup from '../ui/field/FieldGroup.vue'
import Field from '../ui/field/Field.vue'
import FieldLabel from '../ui/field/FieldLabel.vue'
import FieldError from '../ui/field/FieldError.vue'
import Button from '../ui/button/Button.vue'
import Textarea from '../ui/textarea/Textarea.vue'
import { Plus, X } from 'lucide-vue-next';
import FieldSet from '../ui/field/FieldSet.vue'
import FieldLegend from '../ui/field/FieldLegend.vue'
import FieldDescription from '../ui/field/FieldDescription.vue'
import Checkbox from '../ui/checkbox/Checkbox.vue'
const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({
validationSchema: toTypedSchema(trainingReportSchema),
initialValues: {
course_id: null,
event_date: "",
remarks: "",
attendees: [],
}
})
// watch(errors, (newErrors) => {
// console.log(newErrors)
// }, { deep: true })
// watch(values, (newErrors) => {
// console.log(newErrors.attendees)
// }, { deep: true })
watch(() => values.course_id, (newCourseId, oldCourseId) => {
if (!oldCourseId) return;
values.attendees.forEach((a, index) => {
setFieldValue(`attendees[${index}].passed_bookwork`, false);
setFieldValue(`attendees[${index}].passed_qual`, false);
});
});
const submitForm = handleSubmit(onSubmit);
function toMySQLDateTime(date: Date): string {
return date
.toISOString() // 2025-11-19T00:00:00.000Z
.slice(0, 23) // keep milliseconds → 2025-11-19T00:00:00.000
.replace("T", " ") + "000"; // becomes → 2025-11-19 00:00:00.000000
}
function onSubmit(vals) {
try {
const clean: CourseEventDetails = {
...vals,
event_date: new Date(vals.event_date),
}
postTrainingReport(clean).then((newID) => {
emit("submit", newID);
});
} catch (err) {
console.error("There was an error submitting the training report", err);
}
}
const { remove, push, fields } = useFieldArray('attendees');
const selectedCourse = computed<Course | undefined>(() => { return trainings.value?.find(c => c.id == values.course_id) })
const trainings = ref<Course[] | null>(null);
const members = ref<Member[] | null>(null);
const eventRoles = ref<CourseAttendeeRole[] | null>(null);
const emit = defineEmits(['submit'])
onMounted(async () => {
trainings.value = await getAllTrainings();
members.value = await getMembers();
eventRoles.value = await getAllAttendeeRoles();
})
</script>
<template>
<form id="trainingForm" @submit.prevent="submitForm" class="flex flex-col gap-5">
<div class="flex gap-5">
<div class="flex-1">
<FieldGroup>
<VeeField v-slot="{ field, errors }" name="course_id">
<Field :data-invalid="!!errors.length">
<FieldLabel class="scroll-m-20 text-lg tracking-tight">Training Course</FieldLabel>
<select v-bind="field"
class="h-9 border rounded p-2 w-auto focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none">
<option value="" disabled>Select a course</option>
<option v-for="course in trainings" :key="course.id" :value="course.id">
{{ course.name }}
</option>
</select>
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</Field>
</VeeField>
</FieldGroup>
</div>
<div class="w-[150px]">
<FieldGroup>
<VeeField v-slot="{ field, errors }" name="event_date">
<Field :data-invalid="!!errors.length">
<FieldLabel class="scroll-m-20 text-lg tracking-tight">Event Date</FieldLabel>
<input type="date" v-bind="field"
class="h-9 border rounded p-2 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none" />
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</Field>
</VeeField>
</FieldGroup>
</div>
</div>
<VeeFieldArray name="attendees" v-slot="{ fields, push, remove }">
<FieldSet class="gap-4">
<FieldLegend class="scroll-m-20 text-lg tracking-tight">Attendees</FieldLegend>
<FieldDescription>Add members who attended this session.</FieldDescription>
<FieldGroup class="gap-4">
<!-- Column Headers -->
<div class="relative">
<div
class="grid grid-cols-[180px_155px_65px_41px_1fr_auto] gap-3 font-medium text-sm text-muted-foreground">
<div>Member</div>
<div>Role</div>
<!-- Bookwork -->
<div class="text-center">Bookwork</div>
<!-- Qual -->
<div class="text-center">Qual</div>
<div>Remarks</div>
<div></div> <!-- empty for remove button -->
</div>
<!-- FLOATING SUPERHEADER -->
<div class="absolute left-[calc(180px+155px+65px/2)] -top-5
w-[106px] text-center text-xs font-medium text-muted-foreground
pointer-events-none">
Pass
</div>
</div>
<!-- Attendee Rows -->
<template v-for="(field, index) in fields" :key="field.key">
<div class="grid grid-cols-[180px_160px_50px_50px_1fr_auto] gap-3 items-center">
<!-- Member Select -->
<VeeField :name="`attendees[${index}].attendee_id`" v-slot="{ field: f, errors: e }">
<div>
<select v-bind="f"
class="border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none">
<option value="">Select member...</option>
<option v-for="m in members" :key="m.member_id" :value="m.member_id">
{{ m.member_name }}
</option>
</select>
<div class="h-4">
<FieldError v-if="e.length" :errors="e" />
</div>
</div>
</VeeField>
<!-- Role Select -->
<VeeField :name="`attendees[${index}].attendee_role_id`" v-slot="{ field: f, errors: e }">
<div>
<select v-bind="f"
class="border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none">
<option value="">Select role...</option>
<option v-for="r in eventRoles" :key="r.id" :value="r.id">
{{ r.name }}
</option>
</select>
<div class="h-4">
<FieldError v-if="e.length" :errors="e" />
</div>
</div>
</VeeField>
<!-- Bookwork Checkbox -->
<VeeField v-slot="{ field }" :name="`attendees[${index}].passed_bookwork`" type="checkbox"
:value="false" :unchecked-value="true">
<div class="flex flex-col items-center">
<div class="relative inline-flex items-center group">
<Checkbox :disabled="!selectedCourse?.hasBookwork"
:name="`attendees[${index}].passed_bookwork`" :model-value="!field.checked"
@update:model-value="field['onUpdate:modelValue']">
</Checkbox>
<!-- Tooltip bubble -->
<div v-if="!selectedCourse?.hasBookwork" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
text-popover-foreground shadow-md border border-border
opacity-0 translate-y-1
group-hover:opacity-100 group-hover:translate-y-0
transition-opacity transition-transform duration-150">
This course does not have bookwork
</div>
</div>
<div class="h-4">
</div>
</div>
</VeeField>
<!-- Qual Checkbox -->
<VeeField v-slot="{ field }" :name="`attendees[${index}].passed_qual`" type="checkbox"
:value="false" :unchecked-value="true">
<div class="flex flex-col items-center">
<div class="relative inline-flex items-center group">
<Checkbox :disabled="!selectedCourse?.hasQual"
:name="`attendees[${index}].passed_qual`" :model-value="!field.checked"
@update:model-value="field['onUpdate:modelValue']"></Checkbox>
<!-- Tooltip bubble -->
<div v-if="!selectedCourse?.hasQual" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
text-popover-foreground shadow-md border border-border
opacity-0 translate-y-1
group-hover:opacity-100 group-hover:translate-y-0
transition-opacity transition-transform duration-150">
This course does not have a qualification
</div>
</div>
<div class="h-4">
</div>
</div>
</VeeField>
<!-- Remarks -->
<VeeField :name="`attendees[${index}].remarks`" v-slot="{ field: f, errors: e }">
<div class="flex flex-col">
<textarea v-bind="f"
class="h-[38px] resize-none border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none"
placeholder="Optional remarks"></textarea>
<div class="h-4">
<FieldError v-if="e.length" :errors="e" />
</div>
</div>
</VeeField>
<div>
<!-- Remove button -->
<Button type="button" variant="ghost" size="sm" @click="remove(index)"
class="self-center">
<X :size="10" />
</Button>
<div class="h-4">
</div>
</div>
</div>
</template>
</FieldGroup>
<Button type="button" size="sm" variant="outline"
@click="push({ attendee_id: null, attendee_role_id: null, passed_bookwork: false, passed_qual: false, remarks: '' })">
<Plus class="mr-1 h-4 w-4" />
Add Attendee
</Button>
</FieldSet>
</VeeFieldArray>
<FieldGroup class="pt-3">
<VeeField v-slot="{ field, errors }" name="remarks">
<Field :data-invalid="!!errors.length">
<FieldLabel class="scroll-m-20 text-lg tracking-tight">Remarks</FieldLabel>
<Textarea v-bind="field" placeholder="Any remarks about this training event..."
autocomplete="off" />
<FieldError v-if="errors.length" :errors="errors" />
</Field>
</VeeField>
</FieldGroup>
<div class="flex gap-3 justify-end">
<Button type="button" variant="outline" @click="resetForm">Reset</Button>
<Button type="submit" form="trainingForm">Submit</Button>
</div>
</form>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
import { cn } from "@/lib/utils";
import { buttonGroupVariants } from ".";
const props = defineProps({
class: { type: null, required: false },
orientation: { type: null, required: false },
});
</script>
<template>
<div
role="group"
data-slot="button-group"
:data-orientation="props.orientation"
:class="
cn(buttonGroupVariants({ orientation: props.orientation }), props.class)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { cn } from "@/lib/utils";
import { Separator } from '@/components/ui/separator';
const props = defineProps({
orientation: { type: String, required: false, default: "vertical" },
decorative: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Separator
data-slot="button-group-separator"
v-bind="delegatedProps"
:orientation="props.orientation"
:class="
cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
props.class,
)
"
/>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
orientation: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: "div" },
});
</script>
<template>
<Primitive
role="group"
data-slot="button-group"
:data-orientation="props.orientation"
:as="as"
:as-child="asChild"
:class="
cn(
'bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,22 @@
import { cva } from "class-variance-authority";
export { default as ButtonGroup } from "./ButtonGroup.vue";
export { default as ButtonGroupSeparator } from "./ButtonGroupSeparator.vue";
export { default as ButtonGroupText } from "./ButtonGroupText.vue";
export const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);

View File

@@ -0,0 +1,20 @@
<script setup>
import { cn } from "@/lib/utils";
import { fieldVariants } from ".";
const props = defineProps({
class: { type: null, required: false },
orientation: { type: null, required: false },
});
</script>
<template>
<div
role="group"
data-slot="field"
:data-orientation="orientation"
:class="cn(fieldVariants({ orientation }), props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-content"
:class="
cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<p
data-slot="field-description"
:class="
cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
props.class,
)
"
>
<slot />
</p>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import { computed } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
errors: { type: Array, required: false },
});
const content = computed(() => {
if (!props.errors || props.errors.length === 0) return null;
const uniqueErrors = [
...new Map(
props.errors.filter(Boolean).map((error) => {
const message = typeof error === "string" ? error : error?.message;
return [message, error];
}),
).values(),
];
if (uniqueErrors.length === 1 && uniqueErrors[0]) {
return typeof uniqueErrors[0] === "string"
? uniqueErrors[0]
: uniqueErrors[0].message;
}
return uniqueErrors.map((error) =>
typeof error === "string" ? error : error?.message,
);
});
</script>
<template>
<div
v-if="$slots.default || content"
role="alert"
data-slot="field-error"
:class="cn('text-destructive text-sm font-normal', props.class)"
>
<slot v-if="$slots.default" />
<template v-else-if="typeof content === 'string'">
{{ content }}
</template>
<ul
v-else-if="Array.isArray(content)"
class="ml-4 flex list-disc flex-col gap-1"
>
<li v-for="(error, index) in content" :key="index">
{{ error }}
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-group"
:class="
cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
import { cn } from "@/lib/utils";
import { Label } from '@/components/ui/label';
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<Label
data-slot="field-label"
:class="
cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
variant: { type: String, required: false },
});
</script>
<template>
<legend
data-slot="field-legend"
:data-variant="variant"
:class="
cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
props.class,
)
"
>
<slot />
</legend>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import { cn } from "@/lib/utils";
import { Separator } from '@/components/ui/separator';
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-separator"
:data-content="!!$slots.default"
:class="
cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
props.class,
)
"
>
<Separator class="absolute inset-0 top-1/2" />
<span
v-if="$slots.default"
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
<slot />
</span>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<fieldset
data-slot="field-set"
:class="
cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
props.class,
)
"
>
<slot />
</fieldset>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-label"
:class="
cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,36 @@
import { cva } from "class-variance-authority";
export const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
},
);
export { default as Field } from "./Field.vue";
export { default as FieldContent } from "./FieldContent.vue";
export { default as FieldDescription } from "./FieldDescription.vue";
export { default as FieldError } from "./FieldError.vue";
export { default as FieldGroup } from "./FieldGroup.vue";
export { default as FieldLabel } from "./FieldLabel.vue";
export { default as FieldLegend } from "./FieldLegend.vue";
export { default as FieldSeparator } from "./FieldSeparator.vue";
export { default as FieldSet } from "./FieldSet.vue";
export { default as FieldTitle } from "./FieldTitle.vue";

View File

@@ -0,0 +1,36 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="input-group"
role="group"
:class="
cn(
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
'h-9 min-w-0 has-[>textarea]:h-auto',
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
// Error state.
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,32 @@
<script setup>
import { cn } from "@/lib/utils";
import { inputGroupAddonVariants } from ".";
const props = defineProps({
align: { type: null, required: false, default: "inline-start" },
class: { type: null, required: false },
});
function handleInputGroupAddonClick(e) {
const currentTarget = e.currentTarget;
const target = e.target;
if (target && target.closest("button")) {
return;
}
if (currentTarget && currentTarget?.parentElement) {
currentTarget.parentElement?.querySelector("input")?.focus();
}
}
</script>
<template>
<div
role="group"
data-slot="input-group-addon"
:data-align="props.align"
:class="cn(inputGroupAddonVariants({ align: props.align }), props.class)"
@click="handleInputGroupAddonClick"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,47 @@
import { cva } from "class-variance-authority";
export { default as InputGroup } from "./InputGroup.vue";
export { default as InputGroupAddon } from "./InputGroupAddon.vue";
export { default as InputGroupButton } from "./InputGroupButton.vue";
export { default as InputGroupInput } from "./InputGroupInput.vue";
export { default as InputGroupText } from "./InputGroupText.vue";
export { default as InputGroupTextarea } from "./InputGroupTextarea.vue";
export const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
export const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
},
);

View File

@@ -0,0 +1,26 @@
<script setup>
import { SelectRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
defaultValue: { type: null, required: false },
modelValue: { type: null, required: false },
by: { type: [String, Function], required: false },
dir: { type: String, required: false },
multiple: { type: Boolean, required: false },
autocomplete: { type: String, required: false },
disabled: { type: Boolean, required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },
});
const emits = defineEmits(["update:modelValue", "update:open"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<SelectRoot v-slot="slotProps" data-slot="select" v-bind="forwarded">
<slot v-bind="slotProps" />
</SelectRoot>
</template>

View File

@@ -0,0 +1,81 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
forceMount: { type: Boolean, required: false },
position: { type: String, required: false, default: "popper" },
bodyLock: { type: Boolean, required: false },
side: { type: null, required: false },
sideOffset: { type: Number, required: false },
sideFlip: { type: Boolean, required: false },
align: { type: null, required: false },
alignOffset: { type: Number, required: false },
alignFlip: { type: Boolean, required: false },
avoidCollisions: { type: Boolean, required: false },
collisionBoundary: { type: null, required: false },
collisionPadding: { type: [Number, Object], required: false },
arrowPadding: { type: Number, required: false },
sticky: { type: String, required: false },
hideWhenDetached: { type: Boolean, required: false },
positionStrategy: { type: String, required: false },
updatePositionStrategy: { type: String, required: false },
disableUpdateOnLayoutShift: { type: Boolean, required: false },
prioritizePosition: { type: Boolean, required: false },
reference: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"closeAutoFocus",
"escapeKeyDown",
"pointerDownOutside",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'p-1',
position === 'popper' &&
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
import { SelectGroup } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<SelectGroup data-slot="select-group" v-bind="props">
<slot />
</SelectGroup>
</template>

View File

@@ -0,0 +1,49 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
value: { type: null, required: true },
disabled: { type: Boolean, required: false },
textValue: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class,
)
"
>
<span class="absolute right-2 flex size-3.5 items-center justify-center">
<SelectItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
import { SelectItemText } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<SelectItemText data-slot="select-item-text" v-bind="props">
<slot />
</SelectItemText>
</template>

View File

@@ -0,0 +1,20 @@
<script setup>
import { SelectLabel } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
for: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)"
>
<slot />
</SelectLabel>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { SelectScrollDownButton, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectScrollDownButton
data-slot="select-scroll-down-button"
v-bind="forwardedProps"
:class="
cn('flex cursor-default items-center justify-center py-1', props.class)
"
>
<slot>
<ChevronDown class="size-4" />
</slot>
</SelectScrollDownButton>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronUp } from "lucide-vue-next";
import { SelectScrollUpButton, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectScrollUpButton
data-slot="select-scroll-up-button"
v-bind="forwardedProps"
:class="
cn('flex cursor-default items-center justify-center py-1', props.class)
"
>
<slot>
<ChevronUp class="size-4" />
</slot>
</SelectScrollUpButton>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { SelectSeparator } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@@ -0,0 +1,37 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
disabled: { type: Boolean, required: false },
reference: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
size: { type: String, required: false, default: "default" },
});
const delegatedProps = reactiveOmit(props, "class", "size");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="
cn(
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import { SelectValue } from "reka-ui";
const props = defineProps({
placeholder: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<SelectValue data-slot="select-value" v-bind="props">
<slot />
</SelectValue>
</template>

View File

@@ -0,0 +1,11 @@
export { default as Select } from "./Select.vue";
export { default as SelectContent } from "./SelectContent.vue";
export { default as SelectGroup } from "./SelectGroup.vue";
export { default as SelectItem } from "./SelectItem.vue";
export { default as SelectItemText } from "./SelectItemText.vue";
export { default as SelectLabel } from "./SelectLabel.vue";
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue";
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue";
export { default as SelectSeparator } from "./SelectSeparator.vue";
export { default as SelectTrigger } from "./SelectTrigger.vue";
export { default as SelectValue } from "./SelectValue.vue";

View File

@@ -0,0 +1,28 @@
import { ref, watch, onMounted } from "vue";
import { getMonthCalendarEvents } from "@/api/calendar";
import type { CalendarEventShort } from "@shared/types/calendar";
export function useCalendarEvents(selectedMonth, selectedYear) {
const events = ref([]);
function toCalEvent(e: CalendarEventShort) {
return {
id: e.id.toString(),
title: e.name,
start: new Date(e.start),
end: e.end ? new Date(e.end) : undefined,
extendedProps: { color: e.color, cancelled: !!e.cancelled },
};
}
async function loadEvents() {
const date = new Date(selectedYear.value, selectedMonth.value, 1);
const monthEvents = await getMonthCalendarEvents(date);
events.value = monthEvents.map(toCalEvent);
}
watch([selectedMonth, selectedYear], loadEvents);
onMounted(loadEvents);
return { events, loadEvents };
}

View File

@@ -0,0 +1,33 @@
import { ref } from "vue";
export function useCalendarNavigation(calendarApiGetter: () => any) {
const selectedMonth = ref(new Date().getMonth());
const selectedYear = ref(new Date().getFullYear());
const years = Array.from({ length: 41 }, (_, i) => selectedYear.value - 20 + i);
function goPrev() { calendarApiGetter()?.prev(); }
function goNext() { calendarApiGetter()?.next(); }
function goToday() { calendarApiGetter()?.today(); }
function onDatesSet() {
const d = calendarApiGetter()?.getDate() ?? new Date();
selectedMonth.value = d.getMonth();
selectedYear.value = d.getFullYear();
}
function goToSelectedDate() {
calendarApiGetter()?.gotoDate(new Date(selectedYear.value, selectedMonth.value, 1));
}
return {
selectedMonth,
selectedYear,
years,
goPrev,
goNext,
goToday,
onDatesSet,
goToSelectedDate,
};
}

View File

@@ -8,12 +8,42 @@ import router from './router'
import FormCheckbox from './components/form/FormCheckbox.vue' import FormCheckbox from './components/form/FormCheckbox.vue'
import FormInput from './components/form/FormInput.vue' import FormInput from './components/form/FormInput.vue'
import * as Sentry from "@sentry/vue";
import { initMatomo } from "@certible/use-matomo"
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
if (!!import.meta.env.VITE_DISABLE_GLITCHTIP) {
let dsn = import.meta.env.VITE_GLITCHTIP_DSN;
let environment = import.meta.env.VITE_ENVIRONMENT;
Sentry.init({
app,
dsn: dsn,
integrations: [
Sentry.browserTracingIntegration({ router }),
],
tracesSampleRate: 0.01,
environment: environment,
release: 'release tag'
});
}
if (!!import.meta.env.VITE_DISABLE_MATOMO) {
console.log("Matomo init")
app.use(initMatomo({
host: 'https://stats.nexuszone.net/',
siteId: 4,
trackRouter: true,
}));
}
app.component("FormInput", FormInput) app.component("FormInput", FormInput)
app.component("FormCheckbox", FormCheckbox) app.component("FormCheckbox", FormCheckbox)
app.mount('#app') app.mount('#app')
// window._paq.push(['trackPageView']);

View File

@@ -5,85 +5,50 @@ 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'
import ViewCalendarEvent from '@/components/calendar/ViewCalendarEvent.vue'
import { useRouter, useRoute } from 'vue-router'
import { useCalendarEvents } from '@/composables/useCalendarEvents'
import { useCalendarNavigation } from '@/composables/useCalendarNavigation'
const monthLabels = [ const monthLabels = [
'January', 'February', 'March', 'April', 'May', 'June', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December' 'July', 'August', 'September', 'October', 'November', 'December'
] ]
const selectedMonth = ref<number>(new Date().getMonth())
const selectedYear = ref<number>(new Date().getFullYear())
const years = Array.from({ length: 41 }, (_, i) => selectedYear.value - 20 + i) // +/- 20 yrs
function api() { function api() {
return calendarRef.value?.getApi() return calendarRef.value?.getApi()
} }
// keep dropdowns in sync whenever the calendar navigates const router = useRouter();
function onDatesSet() { const route = useRoute();
const d = api()?.getDate() ?? new Date()
selectedMonth.value = d.getMonth()
selectedYear.value = d.getFullYear()
}
function buildFullDate(month: number, year: number): Date { 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
} }
const { selectedMonth, selectedYear, years, goPrev, goNext, goToday, onDatesSet, goToSelectedDate } = useCalendarNavigation(api)
watch([selectedMonth, selectedYear], () => { const { events, loadEvents} = useCalendarEvents(selectedMonth, selectedYear);
console.log('Selected date changed:', selectedMonth.value, selectedYear.value)
})
onMounted(() => {
// fetchEventsFor(selectedMonth.value, selectedYear.value)
})
function goPrev() { api()?.prev() }
function goNext() { api()?.next() }
function goToday() { api()?.today() }
// jump to the selected month/year
function goToSelectedDate() {
api()?.gotoDate(new Date(selectedYear.value, selectedMonth.value, 1))
}
type CalEvent = {
id: string
title: string
start: string
end?: string
extendedProps?: Record<string, any>
}
const events = ref<CalEvent[]>([
{ id: '1', title: 'Squad Training', start: '2025-10-08T19:00:00', extendedProps: { trainer: 'Alex', location: 'Range A', color: '#88C4FF' } },
{ id: '2', title: 'Ops Briefing', start: '2025-10-09T20:30:00', extendedProps: { owner: 'CO', agenda: ['Weather', 'Route', 'Risks'], color: '#dba42c' } },
])
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, router.push(`/calendar/event/${targetEvent}`)
title: arg.event.title,
start: arg.event.startStr,
end: arg.event.endStr,
extendedProps: arg.event.extendedProps
}
panelOpen.value = true panelOpen.value = true
} }
const currentEventID = ref<number | null>(null);
const dialogRef = ref<any>(null) const dialogRef = ref<any>(null)
// NEW: handle day/time slot clicks to start creating an event // NEW: handle day/time slot clicks to start creating an event
function onDateClick(arg: { dateStr: string }) { function onDateClick(arg: { dateStr: string }) {
dialogRef.value?.openDialog(); dialogRef.value?.openDialog(arg.dateStr);
// For now, just open the panel with a draft payload. // For now, just open the panel with a draft payload.
// activeEvent.value = { // activeEvent.value = {
// id: '__draft__', // id: '__draft__',
@@ -109,7 +74,7 @@ const calendarOptions = ref({
navLinks: false, navLinks: false,
dateClick: onDateClick, dateClick: onDateClick,
eventClick: onEventClick, eventClick: onEventClick,
editable: true, editable: false,
// force block-mode in dayGrid so we can lay it out on one line // force block-mode in dayGrid so we can lay it out on one line
eventDisplay: 'block', eventDisplay: 'block',
@@ -123,12 +88,25 @@ const calendarOptions = ref({
// custom renderer -> one-line pill // custom renderer -> one-line pill
eventContent(arg) { eventContent(arg) {
//debug
// console.log("Rendering event:", {
// id: arg.event.id,
// title: arg.event.title,
// extendedProps: arg.event.extendedProps,
// fullEvent: arg.event
// })
const ext = arg.event.extendedProps || {} const ext = arg.event.extendedProps || {}
const c = ext.color || arg.backgroundColor || arg.borderColor || '' const color = ext.color || arg.backgroundColor || arg.borderColor || ''
const isCancelled = !!ext.cancelled;
const wrap = document.createElement('div') const wrap = document.createElement('div')
wrap.className = 'ev-pill' wrap.className = 'ev-pill'
if (c) wrap.style.setProperty('--ev-color', String(c)) // dot color if (color) wrap.style.setProperty('--ev-color', String(color)) // dot color
if (isCancelled) {
wrap.classList.add('is-cancelled')
}
const dot = document.createElement('span') const dot = document.createElement('span')
dot.className = 'ev-dot' dot.className = 'ev-dot'
@@ -149,6 +127,16 @@ const calendarOptions = ref({
//@ts-ignore (shhh) //@ts-ignore (shhh)
calendarOptions.value.datesSet = onDatesSet calendarOptions.value.datesSet = onDatesSet
watch(() => route.params.id, async (newID) => {
if (newID === undefined) {
panelOpen.value = false;
currentEventID.value = null;
} else {
panelOpen.value = true;
currentEventID.value = Number(newID);
}
}, { immediate: true })
watch(panelOpen, async () => { watch(panelOpen, async () => {
await nextTick() await nextTick()
@@ -156,39 +144,22 @@ watch(panelOpen, async () => {
}) })
const startFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit'
})
const endFmt = 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)}`
})
function onCreateEvent() { function onCreateEvent() {
const iso = new Date().toISOString().slice(0, 10) // YYYY-MM-DD const iso = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
onDateClick({ dateStr: iso }) onDateClick({ dateStr: iso })
} }
const eventViewRef = ref(null);
onMounted(() => { onMounted(() => {
onDatesSet() onDatesSet()
}) })
const ext = computed(() => activeEvent.value?.extendedProps ?? {})
</script> </script>
<template> <template>
<CreateCalendarEvent ref="dialogRef"></CreateCalendarEvent> <div>
<CreateCalendarEvent ref="dialogRef" @reload="loadEvents(); eventViewRef.forceReload();"></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">
@@ -199,15 +170,14 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
<!-- <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" aria-label="Select month"> class="ml-2 rounded-md border bg-transparent px-2 py-1 text-sm"
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">
@@ -216,7 +186,6 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
</option> </option>
</select> </select>
</div> </div>
<!-- Right: nav + today + create --> <!-- Right: nav + today + create -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
@@ -240,109 +209,63 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
Create Create
</button> </button>
</div> </div>
</div> </div>
<FullCalendar ref="calendarRef" :options="calendarOptions" /> <FullCalendar ref="calendarRef" :options="calendarOptions" />
</div> </div>
</div> </div>
<aside v-if="panelOpen"
<aside v-if="panelOpen" class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col" 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' }"> :style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
<!-- Header --> <ViewCalendarEvent ref="eventViewRef" @close="() => { router.push('/calendar'); }" @reload="loadEvents()" @edit="(val) => {dialogRef.openDialog(null, 'edit', val)}">
<div class="flex items-center justify-between gap-3 border-b px-4 py-3"> </ViewCalendarEvent>
<h2 class="text-lg font-semibold line-clamp-2">
{{ activeEvent?.title || 'Event' }}
</h2>
<button
class="inline-flex size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition"
aria-label="Close" @click="panelOpen = false">
<X class="size-4" />
</button>
</div>
<!-- Body -->
<div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-6">
<!-- When -->
<section v-if="whenText" class="space-y-2">
<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>
</div>
</section>
<!-- Quick meta chips -->
<section class="flex flex-wrap gap-2">
<span v-if="ext.location"
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">{{ ext.location }}</span>
</span>
<span v-if="ext.owner" 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">Owner: {{ ext.owner }}</span>
</span>
<span v-if="ext.trainer"
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">Trainer: {{ ext.trainer }}</span>
</span>
</section>
<!-- Agenda (special-cased array) -->
<section v-if="Array.isArray(ext.agenda) && ext.agenda.length" class="space-y-2">
<div class="flex items-center gap-2 text-sm font-medium">
<ListTodo class="size-4 opacity-80" />
Agenda
</div>
<ul class="space-y-1.5">
<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>
<span>{{ item }}</span>
</li>
</ul>
</section>
<!-- Generic details (extendedProps minus the ones above) -->
<section v-if="ext && Object.keys(ext).length" class="space-y-3">
<div class="text-sm font-medium opacity-80">Details</div>
<dl class="grid grid-cols-1 gap-y-3">
<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>
</dl>
</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>
</aside> </aside>
</div>
</div> </div>
</template> </template>
<style scoped>
/* Firefox */
.scrollbar-themed {
scrollbar-width: thin;
scrollbar-color: #555 #1f1f1f;
padding-right: 6px;
}
/* Chrome, Edge, Safari */
.scrollbar-themed::-webkit-scrollbar {
width: 10px;
/* slightly wider to allow padding look */
}
.scrollbar-themed::-webkit-scrollbar-track {
background: #1f1f1f;
margin-left: 6px;
/* ❗ adds space between content + scrollbar */
}
.scrollbar-themed::-webkit-scrollbar-thumb {
background: #555;
border-radius: 9999px;
}
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>
<style scoped> <style scoped>
/* ---------- Optional container "card" around the calendar ---------- */ /* ---------- Optional container "card" around the calendar ---------- */
:global(.fc) { :global(.fc) {
height: 100% !important; height: 100% !important;
} }
.calendar-card { :global(.ev-pill.is-cancelled) {
opacity: 0.45;
text-decoration: line-through;
filter: grayscale(100%);
}
:global(.calendar-card) {
background: var(--color-card); background: var(--color-card);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
@@ -370,6 +293,10 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
/* no internal scroll for month grid */ /* no internal scroll for month grid */
} }
:global(.fc-daygrid:hover) {
cursor: pointer;
}
/* Subtle borders everywhere */ /* Subtle borders everywhere */
:global(.fc .fc-scrollgrid), :global(.fc .fc-scrollgrid),
:global(.fc .fc-scrollgrid td), :global(.fc .fc-scrollgrid td),
@@ -431,6 +358,7 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
text-decoration: none; text-decoration: none;
height: 26px;
} }
/* Today: soft background + stronger number */ /* Today: soft background + stronger number */
@@ -454,7 +382,6 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
background: transparent; background: transparent;
color: var(--color-foreground); color: var(--color-foreground);
border-radius: 4px; border-radius: 4px;
padding: 4px 8px;
margin: 2px 6px; margin: 2px 6px;
text-decoration: none; text-decoration: none;
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease; transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
@@ -465,14 +392,7 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
border-color: color-mix(in oklab, var(--color-foreground) 12%, var(--color-border)); border-color: color-mix(in oklab, var(--color-foreground) 12%, var(--color-border));
} }
/* One-line custom pill content (our renderer) */
:global(.ev-pill) {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: inherit;
}
:global(.ev-dot) { :global(.ev-dot) {
width: 8px; width: 8px;
@@ -499,23 +419,22 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
} }
/* One-line custom pill */ /* One-line custom pill */
.ev-pill { :global(.ev-pill) {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
padding: 4px 8px; padding: 4px 8px;
border-radius: 14px; border-radius: 6px;
border: 1px solid var(--color-border); background: color-mix(in srgb, var(--ev-color) 15%, transparent);
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
color: var(--color-foreground);
text-decoration: none; text-decoration: none;
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease; transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
} }
.ev-pill:hover { :global(.ev-pill:hover) {
background: color-mix(in oklab, var(--color-primary) 20%, transparent); /* background: color-mix(in oklab, var(--color-primary) 20%, transparent);
border-color: color-mix(in oklab, var(--color-primary) 45%, var(--color-border)); border-color: color-mix(in oklab, var(--color-primary) 45%, var(--color-border)); */
cursor: pointer;
} }
.ev-dot { .ev-dot {
@@ -544,15 +463,17 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
color: var(--color-primary-foreground); color: var(--color-primary-foreground);
} }
:global(.fc-daygrid-top) {
margin-bottom: 2px;
}
/* --- Replace the default today highlight with a round badge --- */ /* --- Replace the default today highlight with a round badge --- */
:global(.fc .fc-daygrid-day.fc-day-today) { :global(.fc .fc-daygrid-day.fc-day-today) {
background: transparent; background: transparent;
} }
:global(.fc .fc-daygrid-day.fc-day-today .fc-daygrid-day-number) { :global(.fc .fc-daygrid-day.fc-day-today .fc-daygrid-day-number) {
display: inline-block; border-radius: 6px;
padding: 2px 8px;
border-radius: 9999px;
background: color-mix(in oklab, var(--color-primary) 100%, transparent); background: color-mix(in oklab, var(--color-primary) 100%, transparent);
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;

View File

@@ -0,0 +1,314 @@
<script setup lang="ts">
import { getTrainingReport, getTrainingReports } from '@/api/trainingReport';
import { CourseAttendee, CourseEventDetails, CourseEventSummary } from '@shared/types/course';
import { computed, onMounted, ref, watch } from 'vue';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ArrowUpDown, Funnel, Plus, Search, X } from 'lucide-vue-next';
import Button from '@/components/ui/button/Button.vue';
import TrainingReportForm from '@/components/trainingReport/trainingReportForm.vue';
import Checkbox from '@/components/ui/checkbox/Checkbox.vue';
import { useRoute, useRouter } from 'vue-router';
import Select from '@/components/ui/select/Select.vue';
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue';
import SelectValue from '@/components/ui/select/SelectValue.vue';
import SelectContent from '@/components/ui/select/SelectContent.vue';
import SelectItem from '@/components/ui/select/SelectItem.vue';
import Input from '@/components/ui/input/Input.vue';
enum sidePanelState { view, create, closed };
const trainingReports = ref<CourseEventSummary[] | null>(null);
const loaded = ref(false);
const route = useRoute();
const router = useRouter();
const sidePanel = computed<sidePanelState>(() => {
if (route.path.endsWith('/new')) return sidePanelState.create;
if (route.params.id) return sidePanelState.view;
return sidePanelState.closed;
})
watch(() => route.params.id, async (newID) => {
if (!newID) {
focusedTrainingReport.value = null;
return;
}
viewTrainingReport(Number(route.params.id));
})
const focusedTrainingReport = ref<CourseEventDetails | null>(null);
const focusedTrainingTrainees = computed<CourseAttendee[] | null>(() => {
if (focusedTrainingReport.value == null) return null;
return focusedTrainingReport.value.attendees.filter((attendee) => attendee.role.name == 'Trainee');
})
const focusedNoShows = computed<CourseAttendee[] | null>(() => {
if (focusedTrainingReport.value == null) return null;
return focusedTrainingReport.value.attendees.filter((attendee) => attendee.role.name == 'No-Show');
})
const focusedTrainingTrainers = computed<CourseAttendee[] | null>(() => {
if (focusedTrainingReport.value == null) return null;
return focusedTrainingReport.value.attendees.filter((attendee) => attendee.role.name != 'Trainee' && attendee.role.name != 'No-Show');
})
async function viewTrainingReport(id: number) {
focusedTrainingReport.value = await getTrainingReport(id);
}
async function closeTrainingReport() {
router.push(`/trainingReport`)
focusedTrainingReport.value = null;
}
const sortMode = ref<string>("descending");
const searchString = ref<string>("");
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
watch(searchString, (newValue) => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
loadTrainingReports();
}, 300); // 300ms debounce
});
watch(() => sortMode.value, async (newSortMode) => {
loadTrainingReports();
})
async function loadTrainingReports() {
trainingReports.value = await getTrainingReports(sortMode.value, searchString.value);
}
onMounted(async () => {
loadTrainingReports();
if (route.params.id)
viewTrainingReport(Number(route.params.id))
loaded.value = true;
})
</script>
<template>
<div class="px-20 mx-auto max-w-[100rem] flex mt-5">
<!-- training report list -->
<div class="px-4 my-3" :class="sidePanel == sidePanelState.closed ? 'w-full' : 'w-2/5'">
<div class="flex justify-between mb-4">
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">Training Reports</p>
<Button @click="router.push('/trainingReport/new')">
<Plus></Plus> New Training Report
</Button>
</div>
<!-- search/filter -->
<div class="flex justify-between">
<!-- <Search></Search>
<Funnel></Funnel> -->
<div></div>
<div class="flex flex-row gap-5">
<div>
<label class="text-muted-foreground">Search</label>
<Input v-model="searchString"></Input>
</div>
<div class="flex flex-col">
<label class="text-muted-foreground">Sort By</label>
<Select v-model="sortMode">
<SelectTrigger>
<SelectValue placeholder="Sort By" />
<!-- <ArrowUpDown></ArrowUpDown> -->
</SelectTrigger>
<SelectContent>
<SelectItem value="ascending">
Date (Ascending)
</SelectItem>
<SelectItem value="descending">
Date (Descending)
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div class="overflow-y-auto max-h-[70vh] mt-5 scrollbar-themed">
<Table>
<TableHeader class="">
<TableRow>
<TableHead class="w-[100px]">
Training
</TableHead>
<TableHead>Date</TableHead>
<TableHead class="text-right">
Posted By
</TableHead>
</TableRow>
</TableHeader>
<TableBody v-if="loaded">
<TableRow class="cursor-pointer" v-for="report in trainingReports" :key="report.event_id"
@click="router.push(`/trainingReport/${report.event_id}`)">
<TableCell class="font-medium">{{ report.course_name.length > 30 ? report.course_shortname :
report.course_name }}</TableCell>
<TableCell>{{ report.date.split('T')[0] }}</TableCell>
<TableCell class="text-right">{{ report.created_by_name === null ? "Unknown User" :
report.created_by_name
}}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<!-- view training report section -->
<div v-if="focusedTrainingReport != null && sidePanel == sidePanelState.view" class="pl-9 my-3 border-l w-3/5">
<div class="flex justify-between">
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">Training Report Details</p>
<button @click="closeTrainingReport" class="cursor-pointer">
<X></X>
</button>
</div>
<div class="max-h-[70vh] overflow-auto scrollbar-themed my-5">
<div class="flex flex-col mb-5 border rounded-lg bg-muted/70 p-2 py-3 px-4">
<p class="scroll-m-20 text-xl font-semibold tracking-tight">{{ focusedTrainingReport.course_name }}
</p>
<div class="flex gap-10">
<p class="text-muted-foreground">{{ focusedTrainingReport.event_date.split('T')[0] }}</p>
<p class="">Created by {{ focusedTrainingReport.created_by_name === null ? "Unknown User" :
focusedTrainingReport.created_by_name
}}
</p>
</div>
</div>
<div class="flex flex-col gap-8 ">
<!-- Trainers -->
<div>
<label class="scroll-m-20 text-xl font-semibold tracking-tight">Trainers</label>
<div class="grid grid-cols-4 py-2 text-sm font-medium text-muted-foreground border-b">
<span>Name</span>
<span class="">Role</span>
<span class="text-right col-span-2">Remarks</span>
</div>
<div v-for="person in focusedTrainingTrainers"
class="grid grid-cols-4 py-2 items-center border-b last:border-none">
<p>{{ person.attendee_name }}</p>
<p class="">{{ person.role.name }}</p>
<p class="col-span-2 text-right px-2"
:class="person.remarks == '' ? 'text-muted-foreground' : ''">
{{ person.remarks == "" ?
'--'
: person.remarks }}</p>
</div>
</div>
<!-- trainees -->
<div>
<div class="flex flex-col">
<label class="scroll-m-20 text-xl font-semibold tracking-tight">Trainees</label>
<div class="grid grid-cols-5 py-2 text-sm font-medium text-muted-foreground border-b">
<span>Name</span>
<span class="">Bookwork</span>
<span class="">Qual</span>
<span class="text-right col-span-2">Remarks</span>
</div>
</div>
<div v-for="person in focusedTrainingTrainees"
class="grid grid-cols-5 py-2 items-center border-b last:border-none">
<p>{{ person.attendee_name }}</p>
<Checkbox :disabled="!focusedTrainingReport.course.hasQual"
:model-value="person.passed_bookwork" class="pointer-events-none ml-5">
</Checkbox>
<Checkbox :disabled="!focusedTrainingReport.course.hasQual"
:model-value="person.passed_qual" class="pointer-events-none ml-1">
</Checkbox>
<p class="col-span-2 text-right px-2"
:class="person.remarks == '' ? 'text-muted-foreground' : ''">
{{ person.remarks == "" ?
'--'
: person.remarks }}</p>
</div>
</div>
<!-- No Shows -->
<div v-if="focusedNoShows.length != 0">
<div class="flex flex-col">
<label class="scroll-m-20 text-xl font-semibold tracking-tight">No Shows</label>
<div class="grid grid-cols-5 py-2 text-sm font-medium text-muted-foreground border-b">
<span>Name</span>
<!-- <span class="">Role</span>
<span class="">Role</span> -->
<div></div>
<div></div>
<span class="text-right col-span-2">Remarks</span>
</div>
</div>
<div v-for="person in focusedNoShows"
class="grid grid-cols-5 py-2 items-center border-b last:border-none">
<p>{{ person.attendee_name }}</p>
<!-- <Checkbox :default-value="person.passed_bookwork ? true : false" class="pointer-events-none">
</Checkbox>
<Checkbox :default-value="person.passed_qual ? true : false" class="pointer-events-none">
</Checkbox> -->
<div></div>
<div></div>
<p class="col-span-2 text-right px-2"
:class="person.remarks == '' ? 'text-muted-foreground' : ''">
{{ person.remarks == "" ?
'--'
: person.remarks }}</p>
</div>
</div>
<div>
<label class="scroll-m-20 text-xl font-semibold tracking-tight">Remarks</label>
<p class="border bg-muted/50 px-3 py-2 rounded-lg min-h-24 my-2"
:class="focusedTrainingReport.remarks == '' ? 'text-muted-foreground' : ''"> {{
focusedTrainingReport.remarks == "" ? 'None' : focusedTrainingReport.remarks }}</p>
</div>
</div>
</div>
</div>
<div v-if="sidePanel == sidePanelState.create" class="pl-7 border-l w-3/5 max-w-5xl">
<div class="flex justify-between my-3">
<div class="flex pl-2 gap-5">
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">New Training Report</p>
</div>
<button @click="closeTrainingReport" class="cursor-pointer">
<X></X>
</button>
</div>
<div class="overflow-y-auto max-h-[70vh] mt-5 scrollbar-themed">
<TrainingReportForm class="w-full pl-2"
@submit="(newID) => { router.push(`/trainingReport/${newID}`); loadTrainingReports() }">
</TrainingReportForm>
</div>
</div>
</div>
</template>
<style scoped>
/* Firefox */
.scrollbar-themed {
scrollbar-width: thin;
scrollbar-color: #555 #1f1f1f;
padding-right: 6px;
}
/* Chrome, Edge, Safari */
.scrollbar-themed::-webkit-scrollbar {
width: 10px;
/* slightly wider to allow padding look */
}
.scrollbar-themed::-webkit-scrollbar-track {
background: #1f1f1f;
margin-left: 6px;
/* ❗ adds space between content + scrollbar */
}
.scrollbar-themed::-webkit-scrollbar-thumb {
background: #555;
border-radius: 9999px;
}
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>

View File

@@ -2,7 +2,7 @@ import { useUserStore } from '@/stores/user'
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(),
routes: [ routes: [
// PUBLIC // PUBLIC
{ path: '/join', component: () => import('@/pages/Join.vue') }, { path: '/join', component: () => import('@/pages/Join.vue') },
@@ -15,7 +15,13 @@ const router = createRouter({
{ path: '/members', component: () => import('@/pages/memberList.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/members', component: () => import('@/pages/memberList.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } }, { 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: '/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', 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: '/trainingReport', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ 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 } },
// ADMIN / STAFF ROUTES // ADMIN / STAFF ROUTES
{ {
@@ -37,6 +43,9 @@ const router = createRouter({
] ]
}) })
const addr = import.meta.env.VITE_APIHOST;
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
const user = useUserStore() const user = useUserStore()
@@ -49,7 +58,7 @@ router.beforeEach(async (to) => {
if (to.meta.requiresAuth && !user.isLoggedIn) { if (to.meta.requiresAuth && !user.isLoggedIn) {
// Redirect back to original page after login // Redirect back to original page after login
const redirectUrl = encodeURIComponent(window.location.origin + to.fullPath) const redirectUrl = encodeURIComponent(window.location.origin + to.fullPath)
window.location.href = `https://aj17thdevapi.nexuszone.net/login?redirect=${redirectUrl}` window.location.href = `${addr}/login?redirect=${redirectUrl}`
return false // Prevent Vue Router from continuing return false // Prevent Vue Router from continuing
} }

View File

@@ -2,7 +2,7 @@
"compilerOptions": { "compilerOptions": {
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],
"@shared": ["../shared/*"] "@shared/*": ["../shared/*"]
} }
}, },
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]

View File

@@ -15,7 +15,8 @@ export default defineConfig({
], ],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(new URL('../shared', import.meta.url)),
}, },
}, },
server: { server: {