133 Commits

Author SHA1 Message Date
ea52be83ef Create dossier
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m29s
2025-12-06 15:40:51 -06:00
9c903c9ad9 Create dossier
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m19s
2025-12-05 17:59:04 -06:00
5a7b3ba2ab fixed application form sizing
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m12s
2025-12-03 19:23:50 -05:00
2de6b18135 Fixed scrolling on unauthroized page 2025-12-03 19:16:10 -05:00
aedcbd9492 Sticky'd navbar
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m11s
2025-12-03 19:14:47 -05:00
f985e0234c Merge pull request 'Onboarding-Reworko' (#52) from Onboarding-Reworko into main
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m11s
Reviewed-on: #52
2025-12-03 16:58:17 -06:00
66ad8df0c1 Merge branch 'main' into Onboarding-Reworko
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m10s
2025-12-03 16:58:03 -06:00
9bc3098d58 Merge pull request 'Navbar-Rework' (#50) from Navbar-Rework into main
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m11s
Reviewed-on: #50
2025-12-03 16:57:56 -06:00
5aef2f6b73 Merge branch 'main' into Navbar-Rework
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m11s
2025-12-03 16:57:26 -06:00
1faae36abc Merge pull request 'Added more strict form validation rules with user feedback' (#51) from Training-Report-Validation into main
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m11s
Reviewed-on: #51
2025-12-03 16:57:14 -06:00
cf98bba0cd fixed loading placeholder location 2025-12-03 17:56:59 -05:00
d31cb50d71 Added navigation to application stuff under the profile menu 2025-12-03 17:53:31 -05:00
4e6745553b added application view to final stage of onboarding 2025-12-03 17:44:41 -05:00
faf183a23d fixed scrolling issues 2025-12-03 17:01:52 -05:00
3449dcec5c minor UX refinements 2025-12-03 16:05:47 -05:00
12d538dafc vastly modified acceptance/denial display 2025-12-03 15:36:49 -05:00
b79e78c2a6 overhauled recruiter tools 2025-12-03 15:20:37 -05:00
b8f18c060e Mega recruitment pipeline overhaul 2025-12-03 13:37:03 -05:00
41cdd0b74f Added more strict form validation rules with user feedback
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m55s
2025-12-03 00:55:53 -05:00
c537ef9b60 fixed incorrect account status evaluation 2025-12-02 20:31:01 -05:00
26fd323f43 Guest navigation state 2025-12-02 00:09:51 -05:00
9fe18f6b1a Admin navigation permissions 2025-12-01 23:57:26 -05:00
9ac885da56 Changed LOA form label 2025-12-01 19:59:33 -05:00
35cb149202 navbar V2 hell yeah 2025-12-01 18:42:06 -05:00
b40f37c959 cerate deployment configuration
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 2m16s
add continuous deployment yaml to execute on every push to main to build and make live
2025-11-30 18:18:36 -06:00
5a31d86969 Split navbar from main app 2025-11-30 18:55:41 -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
106 changed files with 5936 additions and 691 deletions

View File

@@ -0,0 +1,62 @@
name: Continuous Deployment
on:
push:
jobs:
Deploy:
name: Update Deployment
runs-on: ubuntu-latest
container:
volumes:
- /var/www/html/milsim-site-v4:/var/www/html/milsim-site-v4:rw
steps:
- name: Setup Local Environment
run: |
groupadd -g 989 nginx || true
useradd nginx -u 990 -g nginx -m || true
- name: Verify Node Environment
run: |
npm -v
node -v
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: 'main'
- name: Token Copy
run: |
cd /var/www/html/milsim-site-v4
cp /workspace/17th-Ranger-Battalion-ORG/milsim-site-v4/.git/config .git/config
chown nginx:nginx .git/config
- name: Fix File Permissions
run: |
sudo chown -R nginx:nginx /var/www/html/milsim-site-v4
sudo chmod -R u+w /var/www/html/milsim-site-v4
- name: Update Application Code
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4 && git reset --hard && git pull origin main"
- name: Update Shared Dependencies
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/shared && npm install"
- name: Update UI Dependencies
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/ui && npm install"
- name: Update API Dependencies
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/api && npm install"
- name: Build UI
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/ui && npm run build"
- name: Build API
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/api && npm run build"

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

1443
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

@@ -38,12 +38,27 @@ router.get('/all', async (req, res) => {
}); });
router.get('/me', async (req, res) => { router.get('/me', async (req, res) => {
let userID = req.user.id; let userID = req.user.id;
console.log("application/me") try {
let application = await getMemberApplication(userID);
let app = getMemberApplication(userID); if (application === undefined)
console.log(app); res.sendStatus(204);
const comments: CommentRow[] = await getApplicationComments(application.id);
const output: ApplicationFull = {
application,
comments,
}
return res.status(200).json(output);
} catch (error) {
console.error('Failed to load application:', error);
return res.status(500).json(error);
}
}) })
// GET /application/:id // GET /application/:id

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;
}
res.status(200).json(events); const events = await getShortEventsInRange(fromDate, toDate);
res.status(200).json(events);
} catch (error) {
console.error('Error fetching calendar events:', error);
res.status(500).send('Error fetching calendar events');
}
}); });
r.get('/upcoming', async (req, res) => { r.get('/upcoming', async (req, res) => {
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;
};
}
}
}

View File

@@ -1,4 +1,5 @@
import pool from '../db'; import pool from '../db';
import { Role } from '@app/shared/types/roles'
export async function assignUserGroup(userID: number, roleID: number) { export async function assignUserGroup(userID: number, roleID: number) {
@@ -16,7 +17,7 @@ export async function createGroup(name: string, color: string, description: stri
return { id: result.insertId, name, color, description }; return { id: result.insertId, name, color, description };
} }
export async function getUserRoles(userID: number) { export async function getUserRoles(userID: number): Promise<Role[]> {
const sql = `SELECT r.id, r.name const sql = `SELECT r.id, r.name
FROM members_roles mr FROM members_roles mr
INNER JOIN roles r ON mr.role_id = r.id INNER JOIN roles r ON mr.role_id = r.id

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,60 @@
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([]),
}).superRefine((data, ctx) => {
const trainerRole = 1;
const traineeRole = 2;
const hasTrainer = data.attendees.some((a) => a.attendee_role_id === trainerRole);
const hasTrainee = data.attendees.some((a) => a.attendee_role_id === traineeRole);
if (!hasTrainer) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["attendees"],
message: "At least one Primary Trainer is required.",
});
}
if (!hasTrainee) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["attendees"],
message: "At least one Trainee is required.",
});
}
//no duplicates
const idCounts = new Map<number, number>();
data.attendees.forEach((a, index) => {
idCounts.set(a.attendee_id, (idCounts.get(a.attendee_id) ?? 0) + 1);
if (idCounts.get(a.attendee_id)! > 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["attendees"],
message: "Cannot have duplicate attendee.",
});
}
})
})

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;
}

6
shared/types/roles.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface Role {
id: number;
name: string;
color?: string;
description?: 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}`;
}

7
ui/.env.example Normal file
View File

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

106
ui/package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@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 +22,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.1",
"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",
@@ -1392,6 +1393,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 +3333,9 @@
} }
}, },
"node_modules/reka-ui": { "node_modules/reka-ui": {
"version": "2.5.0", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz", "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.1.tgz",
"integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==", "integrity": "sha512-XK7cJDQoNuGXfCNzBBo/81Yg/OgjPwvbabnlzXG2VsdSgNsT6iIkuPBPr+C0Shs+3bb0x0lbPvgQAhMSCKm5Ww==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",

View File

@@ -17,6 +17,7 @@
"@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 +26,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.1",
"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",

BIN
ui/public/17RBN_Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -1,39 +1,13 @@
<script setup> <script setup>
import { RouterLink, RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
import Separator from './components/ui/separator/Separator.vue';
import Button from './components/ui/button/Button.vue'; import Button from './components/ui/button/Button.vue';
import { Popover, PopoverContent, PopoverTrigger } from './components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './components/ui/dropdown-menu';
import { onMounted } from 'vue';
import { useUserStore } from './stores/user'; import { useUserStore } from './stores/user';
import Alert from './components/ui/alert/Alert.vue'; import Alert from './components/ui/alert/Alert.vue';
import AlertDescription from './components/ui/alert/AlertDescription.vue'; import AlertDescription from './components/ui/alert/AlertDescription.vue';
import Navbar from './components/Navigation/Navbar.vue';
const userStore = useUserStore(); const userStore = useUserStore();
// onMounted(async () => {
// const res = await fetch(`${import.meta.env.VITE_APIHOST}/members/me`, {
// credentials: 'include',
// });
// const data = await res.json();
// console.log(data);
// userStore.user = data;
// });
async function logout() {
await fetch(`${import.meta.env.VITE_APIHOST}/logout`, {
method: 'POST',
credentials: 'include',
});
userStore.user = null;
}
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return ""; if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", { return new Date(dateStr).toLocaleDateString("en-US", {
@@ -42,88 +16,28 @@ function formatDate(dateStr) {
day: "numeric", day: "numeric",
}); });
} }
const environment = import.meta.env.VITE_ENVIRONMENT;
</script> </script>
<template> <template>
<div> <div class="flex flex-col min-h-screen">
<div class="flex items-center justify-between px-10"> <div class="sticky top-0 bg-background z-50">
<div></div> <Navbar class="flex"></Navbar>
<div class="h-15 flex items-center justify-center gap-20"> <Alert v-if="environment == 'dev'" class="m-2 mx-auto w-5xl" variant="info">
<RouterLink to="/"> <AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto">
<Button variant="link">Home</Button> <p>This is a development build of the application. Some features will be unavailable or unstable.</p>
</RouterLink> </AlertDescription>
<RouterLink to="/calendar"> </Alert>
<Button variant="link">Calendar</Button> <Alert v-if="userStore.user?.loa?.[0]" class="m-2 mx-auto w-5xl" variant="info">
</RouterLink> <AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto">
<RouterLink to="/members"> <p>You are on LOA until <strong>{{ formatDate(userStore.user?.loa?.[0].end_date) }}</strong></p>
<Button variant="link">Members</Button> <Button variant="secondary">End LOA</Button>
</RouterLink> </AlertDescription>
<Popover> </Alert>
<PopoverTrigger as-child>
<Button variant="link">Forms</Button>
</PopoverTrigger>
<PopoverContent class="flex flex-col gap-4 items-center w-min">
<RouterLink to="/transfer">
<Button variant="link">Transfer Request</Button>
</RouterLink>
<RouterLink to="/trainingReport">
<Button variant="link">Training Report</Button>
</RouterLink>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger as-child>
<Button variant="link">Administration</Button>
</PopoverTrigger>
<PopoverContent class="flex flex-col gap-4 items-center w-min">
<RouterLink to="/administration/rankChange">
<Button variant="link">Promotions</Button>
</RouterLink>
<RouterLink to="/administration/loa">
<Button variant="link">Leave of Absence</Button>
</RouterLink>
<RouterLink to="/administration/transfer">
<Button variant="link">Transfer Requests</Button>
</RouterLink>
<RouterLink to="/administration/applications">
<Button variant="link">Recruitment</Button>
</RouterLink>
<RouterLink to="/administration/roles">
<Button variant="link">Role Management</Button>
</RouterLink>
</PopoverContent>
</Popover>
</div>
<div>
<DropdownMenu v-if="userStore.isLoggedIn">
<DropdownMenuTrigger class="cursor-pointer">
<p>{{ userStore.user.name }}</p>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>My Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>
<RouterLink to="/loa">
Submit LOA
</RouterLink>
</DropdownMenuItem>
<DropdownMenuItem :variant="'destructive'" @click="logout()">Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<a v-else href="https://aj17thdevapi.nexuszone.net/login">Login</a>
</div>
</div> </div>
<Separator></Separator>
<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">
<p>You are on LOA until <strong>{{ formatDate(userStore.user?.loa?.[0].end_date) }}</strong></p>
<Button variant="secondary">End LOA</Button>
</AlertDescription>
</Alert>
<RouterView class=""></RouterView> <RouterView class="flex-1 min-h-0"></RouterView>
</div> </div>
</template> </template>

View File

@@ -74,7 +74,7 @@ export enum ApplicationStatus {
const addr = import.meta.env.VITE_APIHOST; const addr = import.meta.env.VITE_APIHOST;
export async function loadApplication(id: number | string): Promise<ApplicationFull | null> { export async function loadApplication(id: number | string): Promise<ApplicationFull | null> {
const res = await fetch(`${addr}/application/${id}`) const res = await fetch(`${addr}/application/${id}`, { credentials: 'include' })
if (res.status === 204) return null if (res.status === 204) return null
if (!res.ok) throw new Error('Failed to load application') if (!res.ok) throw new Error('Failed to load application')
const json = await res.json() const json = await res.json()

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}`, {
export async function adminCancelCalendarEvent(eventID: number) { method: "POST",
credentials: "include"
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }
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

@@ -16,8 +16,8 @@
--secondary-foreground: oklch(0.9219 0 0); --secondary-foreground: oklch(0.9219 0 0);
--muted: oklch(0.2686 0 0); --muted: oklch(0.2686 0 0);
--muted-foreground: oklch(0.7155 0 0); --muted-foreground: oklch(0.7155 0 0);
--accent: oklch(0.4732 0.1247 46.2007); --accent: oklch(100% 0.00011 271.152 / 0.253);
--accent-foreground: oklch(0.9243 0.1151 95.7459); --accent-foreground: oklch(100% 0.00011 271.152);
--destructive: oklch(0.6368 0.2078 25.3313); --destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0); --destructive-foreground: oklch(1.0000 0 0);
--success: oklch(66.104% 0.16937 144.153); --success: oklch(66.104% 0.16937 144.153);
@@ -52,7 +52,7 @@
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35); --shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35);
} }
.dark { /* .dark {
--background: oklch(0.2046 0 0); --background: oklch(0.2046 0 0);
--foreground: oklch(0.9219 0 0); --foreground: oklch(0.9219 0 0);
--card: oklch(23.075% 0.00003 271.152); --card: oklch(23.075% 0.00003 271.152);
@@ -99,7 +99,7 @@
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 4px 6px -2px hsl(0 0% 0% / 0.14); --shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 4px 6px -2px hsl(0 0% 0% / 0.14);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 8px 10px -2px hsl(0 0% 0% / 0.14); --shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 8px 10px -2px hsl(0 0% 0% / 0.14);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35); --shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35);
} } */
@theme inline { @theme inline {

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import Separator from '../ui/separator/Separator.vue';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { useUserStore } from '@/stores/user';
import Button from '../ui/button/Button.vue';
import NavigationMenu from '../ui/navigation-menu/NavigationMenu.vue';
import NavigationMenuList from '../ui/navigation-menu/NavigationMenuList.vue';
import NavigationMenuItem from '../ui/navigation-menu/NavigationMenuItem.vue';
import NavigationMenuLink from '../ui/navigation-menu/NavigationMenuLink.vue';
import NavigationMenuTrigger from '../ui/navigation-menu/NavigationMenuTrigger.vue';
import NavigationMenuContent from '../ui/navigation-menu/NavigationMenuContent.vue';
import { navigationMenuTriggerStyle } from '../ui/navigation-menu/'
import { useAuth } from '@/composables/useAuth';
import { CircleArrowOutUpRight } from 'lucide-vue-next';
const userStore = useUserStore();
const auth = useAuth();
//@ts-ignore
const APIHOST = import.meta.env.VITE_APIHOST;
async function logout() {
userStore.user = null;
window.location.href = APIHOST + "/logout";
}
function blurAfter() {
requestAnimationFrame(() => {
(document.activeElement as HTMLElement)?.blur();
});
}
</script>
<template>
<div class="w-full border-b">
<div class="max-w-screen-3xl w-full mx-auto flex items-center justify-between pr-10 pl-7">
<!-- left side -->
<div class="flex items-center gap-7">
<RouterLink to="/">
<img src="/17RBN_Logo.png" class="w-10 h-10 object-contain"></img>
</RouterLink>
<!-- Member navigation -->
<div v-if="auth.accountStatus.value == 'member'" class="h-15 flex items-center justify-center">
<NavigationMenu>
<NavigationMenuList class="gap-3">
<!-- Calendar -->
<NavigationMenuItem>
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/calendar" @click="blurAfter">Calendar</RouterLink>
</NavigationMenuLink>
</NavigationMenuItem>
<!-- Members -->
<NavigationMenuItem>
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/" @click="blurAfter">Documents</RouterLink>
</NavigationMenuLink>
</NavigationMenuItem>
<!-- Forms (Dropdown) -->
<NavigationMenuItem class="bg-none !focus:bg-none !active:bg-none">
<NavigationMenuTrigger>Forms</NavigationMenuTrigger>
<NavigationMenuContent
class="grid gap-1 p-2 text-left [&_a]:w-full [&_a]:block [&_a]:whitespace-nowrap *:bg-transparent">
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/loa" @click="blurAfter">
Leave of Absence
</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/transfer" @click="blurAfter">
Transfer Request
</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/trainingReport" @click="blurAfter">
Training Report
</RouterLink>
</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
<!-- Administration (Dropdown) -->
<NavigationMenuItem
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command', 'Recruiter'])">
<NavigationMenuTrigger>Administration</NavigationMenuTrigger>
<NavigationMenuContent
class="grid gap-1 p-2 text-left [&_a]:w-full [&_a]:block [&_a]:whitespace-nowrap *:bg-transparent">
<NavigationMenuLink
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])"
as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/administration/rankChange" @click="blurAfter">
Promotions
</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])"
as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/administration/loa" @click="blurAfter">
Leave of Absence
</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])"
as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/administration/transfer" @click="blurAfter">
Transfer Requests
</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink v-if="auth.hasRole('Recruiter')" as-child
:class="navigationMenuTriggerStyle()">
<RouterLink to="/administration/applications" @click="blurAfter">
Recruitment
</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink v-if="auth.hasRole('17th Administrator')" as-child
:class="navigationMenuTriggerStyle()">
<RouterLink to="/administration/roles" @click="blurAfter">
Role Management
</RouterLink>
</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/members" @click="blurAfter">
Members (debug)
</RouterLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<!-- Guest navigation -->
<div v-else class="h-15 flex items-center justify-center">
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/join" @click="blurAfter">Join</RouterLink>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
</div>
<!-- right side -->
<div>
<DropdownMenu v-if="userStore.isLoggedIn">
<DropdownMenuTrigger class="cursor-pointer">
<p>{{ userStore.user.name }}</p>
</DropdownMenuTrigger>
<DropdownMenuContent>
<!-- <DropdownMenuItem>My Profile</DropdownMenuItem> -->
<!-- <DropdownMenuItem>Settings</DropdownMenuItem> -->
<DropdownMenuItem @click="$router.push('/join')">My Application</DropdownMenuItem>
<DropdownMenuItem :variant="'destructive'" @click="logout()">Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<a v-else :href="APIHOST + '/login'">Login</a>
</div>
</div>
<!-- <Separator></Separator> -->
</div>
</template>

View File

@@ -38,7 +38,7 @@ function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => vo
<Form :validation-schema="commentSchema" @submit="onSubmit"> <Form :validation-schema="commentSchema" @submit="onSubmit">
<FormField name="text" v-slot="{ componentField }"> <FormField name="text" v-slot="{ componentField }">
<FormItem> <FormItem>
<FormLabel class="sr-only">Comment</FormLabel> <FormLabel>Comment</FormLabel>
<FormControl> <FormControl>
<Textarea v-bind="componentField" rows="3" placeholder="Write a comment…" <Textarea v-bind="componentField" rows="3" placeholder="Write a comment…"
class="bg-neutral-800 resize-none w-full" /> class="bg-neutral-800 resize-none w-full" />

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>
<FormMessage /> <div class="h-3">
<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>
<FormMessage /> <div class="h-3">
<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>
<FormMessage /> <div class="h-3">
<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>
<FormMessage /> <div class="h-3">
<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>
<FormMessage /> <div class="h-3">
<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>
<FormMessage /> <div class="h-3">
<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>
<FormMessage /> <div class="h-3">
<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>
<FormMessage /> <div class="h-3">
<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

@@ -101,7 +101,7 @@ function toMariaDBDatetime(date: Date): string {
</script> </script>
<template> <template>
<div class="flex flex-row-reverse gap-6 mx-auto " :class="!adminMode ? 'max-w-5xl' : 'max-w-5xl'"> <div class="flex flex-row-reverse gap-6 mx-auto w-full" :class="!adminMode ? 'max-w-5xl' : 'max-w-5xl'">
<div v-if="!adminMode" class="flex-1 flex space-x-4 rounded-md border p-4"> <div v-if="!adminMode" class="flex-1 flex space-x-4 rounded-md border p-4">
<div class="flex-2 space-y-1"> <div class="flex-2 space-y-1">
<p class="text-sm font-medium leading-none"> <p class="text-sm font-medium leading-none">

View File

@@ -0,0 +1,309 @@
<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'
import { configure } from 'vee-validate'
const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({
validationSchema: toTypedSchema(trainingReportSchema),
validateOnMount: false,
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">
<div class="flex flex-col gap-2">
<FieldLegend class="scroll-m-20 text-lg tracking-tight mb-0">Attendees</FieldLegend>
<FieldDescription class="mb-0">Add members who attended this session.</FieldDescription>
<div class="h-4">
<div class="text-red-500 text-sm"
v-if="errors.attendees && typeof errors.attendees === 'string'">
{{ errors.attendees }}
</div>
</div>
</div>
<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,45 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
import NavigationMenuViewport from "./NavigationMenuViewport.vue";
const props = defineProps({
modelValue: { type: String, required: false },
defaultValue: { type: String, required: false },
dir: { type: String, required: false },
orientation: { type: String, required: false },
delayDuration: { type: Number, required: false },
skipDelayDuration: { type: Number, required: false },
disableClickTrigger: { type: Boolean, required: false },
disableHoverTrigger: { type: Boolean, required: false },
disablePointerLeaveClose: { type: Boolean, required: false },
unmountOnHide: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
viewport: { type: Boolean, required: false, default: true },
});
const emits = defineEmits(["update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class", "viewport");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<NavigationMenuRoot
v-slot="slotProps"
data-slot="navigation-menu"
:data-viewport="viewport"
v-bind="forwarded"
:class="
cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
props.class,
)
"
>
<slot v-bind="slotProps" />
<NavigationMenuViewport v-if="viewport" />
</NavigationMenuRoot>
</template>

View File

@@ -0,0 +1,39 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuContent, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"escapeKeyDown",
"pointerDownOutside",
"focusOutside",
"interactOutside",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<NavigationMenuContent
data-slot="navigation-menu-content"
v-bind="forwarded"
:class="
cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
props.class,
)
"
>
<slot />
</NavigationMenuContent>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuIndicator, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, 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>
<NavigationMenuIndicator
data-slot="navigation-menu-indicator"
v-bind="forwardedProps"
:class="
cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
props.class,
)
"
>
<div
class="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md"
/>
</NavigationMenuIndicator>
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuItem } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
value: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<NavigationMenuItem
data-slot="navigation-menu-item"
v-bind="delegatedProps"
:class="cn('relative', props.class)"
>
<slot />
</NavigationMenuItem>
</template>

View File

@@ -0,0 +1,31 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuLink, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
active: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["select"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<NavigationMenuLink
data-slot="navigation-menu-link"
v-bind="forwarded"
:class="
cn(
'data-active:focus:bg-accent data-active:hover:bg-accent data-active:bg-accent/50 data-active:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*=\'text-\'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<slot />
</NavigationMenuLink>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuList, 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>
<NavigationMenuList
data-slot="navigation-menu-list"
v-bind="forwardedProps"
:class="
cn(
'group flex flex-1 list-none items-center justify-center gap-1',
props.class,
)
"
>
<slot />
</NavigationMenuList>
</template>

View File

@@ -0,0 +1,32 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { NavigationMenuTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { navigationMenuTriggerStyle } from ".";
const props = defineProps({
disabled: { type: Boolean, 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>
<NavigationMenuTrigger
data-slot="navigation-menu-trigger"
v-bind="forwardedProps"
:class="cn(navigationMenuTriggerStyle(), 'group', props.class)"
>
<slot />
<ChevronDown
class="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuTrigger>
</template>

View File

@@ -0,0 +1,32 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuViewport, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
align: { 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>
<div class="absolute top-full left-0 isolate z-50 flex justify-center">
<NavigationMenuViewport
data-slot="navigation-menu-viewport"
v-bind="forwardedProps"
:class="
cn(
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--reka-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--reka-navigation-menu-viewport-width)] left-[var(--reka-navigation-menu-viewport-left)]',
props.class,
)
"
/>
</div>
</template>

View File

@@ -0,0 +1,14 @@
import { cva } from "class-variance-authority";
export { default as NavigationMenu } from "./NavigationMenu.vue";
export { default as NavigationMenuContent } from "./NavigationMenuContent.vue";
export { default as NavigationMenuIndicator } from "./NavigationMenuIndicator.vue";
export { default as NavigationMenuItem } from "./NavigationMenuItem.vue";
export { default as NavigationMenuLink } from "./NavigationMenuLink.vue";
export { default as NavigationMenuList } from "./NavigationMenuList.vue";
export { default as NavigationMenuTrigger } from "./NavigationMenuTrigger.vue";
export { default as NavigationMenuViewport } from "./NavigationMenuViewport.vue";
export const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
);

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,31 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
defaultValue: { type: Number, required: false },
orientation: { type: String, required: false },
dir: { type: String, required: false },
modelValue: { type: Number, required: false },
linear: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<StepperRoot
v-slot="slotProps"
:class="cn('flex gap-2', props.class)"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</StepperRoot>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperDescription, 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 forwarded = useForwardProps(delegatedProps);
</script>
<template>
<StepperDescription
v-slot="slotProps"
v-bind="forwarded"
:class="cn('text-xs text-muted-foreground', props.class)"
>
<slot v-bind="slotProps" />
</StepperDescription>
</template>

View File

@@ -0,0 +1,36 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperIndicator, 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 forwarded = useForwardProps(delegatedProps);
</script>
<template>
<StepperIndicator
v-slot="slotProps"
v-bind="forwarded"
:class="
cn(
'inline-flex items-center justify-center rounded-full text-muted-foreground/50 w-8 h-8',
// Disabled
'group-data-[disabled]:text-muted-foreground group-data-[disabled]:opacity-50',
// Active
'group-data-[state=active]:bg-primary group-data-[state=active]:text-primary-foreground',
// Completed
'group-data-[state=completed]:bg-accent group-data-[state=completed]:text-accent-foreground',
props.class,
)
"
>
<slot v-bind="slotProps" />
</StepperIndicator>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperItem, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
step: { type: Number, required: true },
disabled: { type: Boolean, required: false },
completed: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<StepperItem
v-slot="slotProps"
v-bind="forwarded"
:class="
cn(
'flex items-center gap-2 group data-[disabled]:pointer-events-none',
props.class,
)
"
>
<slot v-bind="slotProps" />
</StepperItem>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperSeparator, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
orientation: { type: String, required: false },
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");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<StepperSeparator
v-bind="forwarded"
:class="
cn(
'bg-muted',
// Disabled
'group-data-[disabled]:bg-muted group-data-[disabled]:opacity-50',
// Completed
'group-data-[state=completed]:bg-accent',
props.class,
)
"
/>
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperTitle, 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 forwarded = useForwardProps(delegatedProps);
</script>
<template>
<StepperTitle
v-bind="forwarded"
:class="cn('text-md font-semibold whitespace-nowrap', props.class)"
>
<slot />
</StepperTitle>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperTrigger, 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 forwarded = useForwardProps(delegatedProps);
</script>
<template>
<StepperTrigger
v-bind="forwarded"
:class="
cn(
'p-1 flex flex-col items-center text-center gap-1 rounded-md',
props.class,
)
"
>
<slot />
</StepperTrigger>
</template>

View File

@@ -0,0 +1,7 @@
export { default as Stepper } from "./Stepper.vue";
export { default as StepperDescription } from "./StepperDescription.vue";
export { default as StepperIndicator } from "./StepperIndicator.vue";
export { default as StepperItem } from "./StepperItem.vue";
export { default as StepperSeparator } from "./StepperSeparator.vue";
export { default as StepperTitle } from "./StepperTitle.vue";
export { default as StepperTrigger } from "./StepperTrigger.vue";

View File

@@ -0,0 +1,36 @@
import { useUserStore } from "@/stores/user"
import { computed } from "vue";
import { Role } from "@shared/types/roles"
export function useAuth() {
const userStore = useUserStore();
// Account status control
const accountStatus = computed(() => userStore.state);
// RBAC
const roles = computed<string[]>(() => {
return userStore.user?.roleData?.map((r: Role) => r.name) ?? [];
});
function isDev() {
return roles.value.includes('Dev');
}
function hasRole(roleName: string): boolean {
if (isDev()) return true;
return roles.value.includes(roleName);
}
function hasAnyRole(roleNames: string[]): boolean {
if (isDev()) return true;
return roles.value.some(name => roleNames.includes(name))
}
function hasAllRoles(roleNames: string[]): boolean {
if (isDev()) return true;
return roles.value.every(name => roleNames.includes(name))
}
return { hasRole, hasAnyRole, hasAllRoles, accountStatus }
}

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,11 +8,34 @@ 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";
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'
});
}
app.component("FormInput", FormInput) app.component("FormInput", FormInput)
app.component("FormCheckbox", FormCheckbox) app.component("FormCheckbox", FormCheckbox)

View File

@@ -17,48 +17,95 @@ const decisionDate = ref<Date | null>(null);
const submitDate = ref<Date | null>(null); const submitDate = ref<Date | null>(null);
const loading = ref<boolean>(true); const loading = ref<boolean>(true);
const member_name = ref<string>(); const member_name = ref<string>();
const props = defineProps<{
mode?: "create" | "view-self" | "view-recruiter"
}>()
const finalMode = ref<"create" | "view-self" | "view-recruiter">("create");
async function loadByID(id: number | string) {
const raw = await loadApplication(id);
const data = raw.application;
appID.value = data.id;
appData.value = data.app_data;
chatData.value = raw.comments;
status.value = data.app_status;
decisionDate.value = new Date(data.decision_at);
submitDate.value = data.submitted_at ? new Date(data.submitted_at) : null;
member_name.value = data.member_name;
newApp.value = false;
readOnly.value = true;
}
const router = useRoute();
onMounted(async () => { onMounted(async () => {
try {
//get app ID from URL param
const router = useRoute();
const appIDRaw = router.params.id;
if (appIDRaw === undefined) {
//new app
appData.value = null
readOnly.value = false;
newApp.value = true;
} else {
//load app
const raw = await loadApplication(appIDRaw.toString());
const data = raw.application; //recruiter mode
if (props.mode === 'view-recruiter') {
appID.value = data.id; finalMode.value = 'view-recruiter';
appData.value = data.app_data; await loadByID(Number(router.params.id));
chatData.value = raw.comments;
status.value = data.app_status;
decisionDate.value = new Date(data.decision_at);
submitDate.value = data.submitted_at ? new Date(data.submitted_at) : null;
member_name.value = data.member_name;
newApp.value = false;
readOnly.value = true;
}
} catch (e) {
console.error(e);
} }
//viewer mode
if (props.mode === 'view-self') {
finalMode.value = 'view-self';
await loadByID('me');
}
//creator mode
if (props.mode === 'create') {
finalMode.value = 'create';
appData.value = null
readOnly.value = false;
newApp.value = true;
}
loading.value = false; loading.value = false;
// try {
// //get app ID from URL param
// if (appIDRaw === undefined) {
// //new app
// appData.value = null
// readOnly.value = false;
// newApp.value = true;
// } else {
// //load app
// const raw = await loadApplication(appIDRaw.toString());
// const data = raw.application;
// appID.value = data.id;
// appData.value = data.app_data;
// chatData.value = raw.comments;
// status.value = data.app_status;
// decisionDate.value = new Date(data.decision_at);
// submitDate.value = data.submitted_at ? new Date(data.submitted_at) : null;
// member_name.value = data.member_name;
// newApp.value = false;
// readOnly.value = true;
// }
// } catch (e) {
// console.error(e);
// }
}) })
async function postComment(comment) { async function postComment(comment) {
chatData.value.push(await postChatMessage(comment, appID.value)); chatData.value.push(await postChatMessage(comment, appID.value));
} }
const emit = defineEmits(['submit']);
async function postApp(appData) { async function postApp(appData) {
console.log("test")
const res = await postApplication(appData); const res = await postApplication(appData);
if (res.ok) { if (res.ok) {
readOnly.value = true; readOnly.value = true;
newApp.value = false; newApp.value = false;
emit('submit');
} }
// TODO: Handle fail to post // TODO: Handle fail to post
} }
@@ -74,7 +121,7 @@ async function handleDeny(id) {
</script> </script>
<template> <template>
<div v-if="!loading" class="max-w-3xl mx-auto my-20"> <div v-if="!loading" class="w-full h-20">
<div v-if="!newApp" class="flex flex-row justify-between items-center py-2 mb-8"> <div v-if="!newApp" class="flex flex-row justify-between items-center py-2 mb-8">
<!-- Application header --> <!-- Application header -->
<div> <div>
@@ -102,7 +149,7 @@ async function handleDeny(id) {
hour: "2-digit", hour: "2-digit",
minute: "2-digit" minute: "2-digit"
}) }}</p> }) }}</p>
<div class="mt-2" v-else> <div class="mt-2" v-else-if="finalMode === 'view-recruiter'">
<Button variant="success" class="mr-2" :onclick="() => { handleApprove(appID) }"> <Button variant="success" class="mr-2" :onclick="() => { handleApprove(appID) }">
<CheckIcon></CheckIcon> <CheckIcon></CheckIcon>
</Button> </Button>
@@ -123,5 +170,5 @@ async function handleDeny(id) {
</div> </div>
</div> </div>
<!-- TODO: Implement some kinda loading screen --> <!-- TODO: Implement some kinda loading screen -->
<div v-else>Loading</div> <div v-else class="flex items-center justify-center h-full">Loading</div>
</template> </template>

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,193 +144,128 @@ 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">
<!-- calendar header --> <!-- calendar header -->
<div class="flex items-center justify-between mx-5"> <div class="flex items-center justify-between mx-5">
<!-- Left: title + pickers --> <!-- Left: title + pickers -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- <h2 class="text-xl font-semibold tracking-tight"> <!-- <h2 class="text-xl font-semibold tracking-tight">
{{ monthLabels[selectedMonth] }} {{ selectedYear }} {{ monthLabels[selectedMonth] }} {{ selectedYear }}
</h2> --> </h2> -->
<!-- Month dropdown -->
<!-- Month dropdown --> <select v-model.number="selectedMonth" @change="goToSelectedDate"
<select v-model.number="selectedMonth" @change="goToSelectedDate" class="ml-2 rounded-md border bg-transparent px-2 py-1 text-sm"
class="ml-2 rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select month"> aria-label="Select month">
<option v-for="(m, i) in monthLabels" :key="m" :value="i" class="bg-card"> <option v-for="(m, i) in monthLabels" :key="m" :value="i" class="bg-card">
{{ m }} {{ m }}
</option> </option>
</select> </select>
<!-- Year dropdown -->
<!-- Year dropdown --> <select v-model.number="selectedYear" @change="goToSelectedDate"
<select v-model.number="selectedYear" @change="goToSelectedDate" class="rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select year">
class="rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select year"> <option v-for="y in years" :key="y" :value="y" class="bg-card">
<option v-for="y in years" :key="y" :value="y" class="bg-card"> {{ y }}
{{ y }} </option>
</option> </select>
</select> </div>
<!-- Right: nav + today + create -->
<div class="flex items-center gap-2">
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Previous month" @click="goPrev">
<ChevronLeft class="h-4 w-4" />
</button>
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Next month" @click="goNext">
<ChevronRight class="h-4 w-4" />
</button>
<button class="cursor-pointer ml-1 rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40"
@click="goToday">
Today
</button>
<button
class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
@click="onCreateEvent">
<Plus class="h-4 w-4" />
Create
</button>
</div>
</div> </div>
<FullCalendar ref="calendarRef" :options="calendarOptions" />
<!-- Right: nav + today + create -->
<div class="flex items-center gap-2">
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Previous month" @click="goPrev">
<ChevronLeft class="h-4 w-4" />
</button>
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Next month" @click="goNext">
<ChevronRight class="h-4 w-4" />
</button>
<button class="cursor-pointer ml-1 rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40"
@click="goToday">
Today
</button>
<button
class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
@click="onCreateEvent">
<Plus class="h-4 w-4" />
Create
</button>
</div>
</div> </div>
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div> </div>
<aside v-if="panelOpen"
class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col overflow-auto scrollbar-themed"
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
<ViewCalendarEvent ref="eventViewRef" @close="() => { router.push('/calendar'); }" @reload="loadEvents()" @edit="(val) => {dialogRef.openDialog(null, 'edit', val)}">
</ViewCalendarEvent>
</aside>
</div> </div>
<aside v-if="panelOpen" class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col"
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
<!-- Header -->
<div class="flex items-center justify-between gap-3 border-b px-4 py-3">
<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>
</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;

110
ui/src/pages/Dossier.vue Normal file
View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
</script>
<template>
<div class="px-10 py-6 max-w-7xl mx-auto w-full">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-semibold tracking-tight">Member Deployments</h1>
<div class="text-muted-foreground">Unit / Dossier / Deployments</div>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="p-5 rounded-xl border bg-card shadow-sm">
<p class="text-muted-foreground text-sm">Total Deployments</p>
<p class="text-3xl font-bold mt-2">123</p>
</div>
<div class="p-5 rounded-xl border bg-card shadow-sm">
<p class="text-muted-foreground text-sm">Total Hours</p>
<p class="text-3xl font-bold mt-2">456h</p>
</div>
<div class="p-5 rounded-xl border bg-card shadow-sm">
<p class="text-muted-foreground text-sm">Avg. Attendance</p>
<p class="text-3xl font-bold mt-2">87%</p>
</div>
</div>
<!-- Filters & Search -->
<div class="flex justify-between items-end mb-4 flex-wrap gap-4">
<div class="flex gap-4 flex-wrap">
<div>
<label class="block text-sm text-muted-foreground mb-1">Operation Type</label>
<select class="border rounded-md px-3 py-2 w-48 bg-background">
<option>All</option>
<option>Deployment</option>
<option>Training</option>
</select>
</div>
<div>
<label class="block text-sm text-muted-foreground mb-1">Sort By</label>
<select class="border rounded-md px-3 py-2 w-48 bg-background">
<option>Date (Newest)</option>
<option>Date (Oldest)</option>
<option>Longest Duration</option>
</select>
</div>
</div>
<div>
<label class="block text-sm text-muted-foreground mb-1">Search</label>
<input type="text" placeholder="Search deployments..." class="border rounded-md px-3 py-2 w-56 bg-background" />
</div>
</div>
<!-- Deployment List -->
<div class="rounded-xl border divide-y bg-card shadow-sm">
<!-- Row -->
<div class="p-5 hover:bg-accent cursor-pointer flex justify-between items-center">
<div>
<p class="font-semibold text-lg">Operation Dawn Strike</p>
<div class="text-sm text-muted-foreground flex gap-6 mt-1">
<span>Date: 2024-08-14</span>
<span>Duration: 3.4h</span>
<span>Role: Rifleman</span>
</div>
</div>
<div class="text-right">
<p class="font-medium">Status: <span class="text-green-500 font-semibold">Completed</span></p>
</div>
</div>
<div class="p-5 hover:bg-accent cursor-pointer flex justify-between items-center">
<div>
<p class="font-semibold text-lg">Operation Iron Resolve</p>
<div class="text-sm text-muted-foreground flex gap-6 mt-1">
<span>Date: 2024-08-02</span>
<span>Duration: 2.1h</span>
<span>Role: Machine Gunner</span>
</div>
</div>
<div class="text-right">
<p class="font-medium">Status: <span class="text-yellow-500 font-semibold">Partial</span></p>
</div>
</div>
<div class="p-5 hover:bg-accent cursor-pointer flex justify-between items-center">
<div>
<p class="font-semibold text-lg">Operation Midnight Gale</p>
<div class="text-sm text-muted-foreground flex gap-6 mt-1">
<span>Date: 2024-07-22</span>
<span>Duration: 4.8h</span>
<span>Role: Squad Leader</span>
</div>
</div>
<div class="text-right">
<p class="font-medium">Status: <span class="text-red-500 font-semibold">NoShow</span></p>
</div>
</div>
</div>
</div>
</template>

View File

@@ -13,17 +13,18 @@ function goToApplication() {
</script> </script>
<template> <template>
<div v-if="user.state == 'guest'" <div>
class="min-h-screen flex flex-col items-center justify-center text-center bg-neutral-950 text-white px-4"> <div v-if="user.state == 'guest'" class="flex flex-col items-center justify-center">
<h1 class="text-4xl font-bold mb-4">Welcome to the 17th</h1> <h1 class="text-4xl font-bold mb-4">Welcome to the 17th</h1>
<p class="text-neutral-400 mb-8 max-w-md"> <p class="text-neutral-400 mb-8 max-w-md">
To join our unit, please fill out an application to continue. To join our unit, please fill out an application to continue.
</p> </p>
<Button @click="goToApplication" class="px-6 py-3 text-lg"> <Button @click="goToApplication" class="px-6 py-3 text-lg">
Begin Application Begin Application
</Button> </Button>
</div> </div>
<div v-else> <div v-else>
HOMEPAGEEEEEEEEEEEEEEEEEEE HOMEPAGEEEEEEEEEEEEEEEEEEE
</div>
</div> </div>
</template> </template>

View File

@@ -1,24 +1,258 @@
<template> <script setup lang="ts">
<div class="min-h-screen flex items-center justify-center "> import ApplicationForm from '@/components/application/ApplicationForm.vue';
<div class="w-full max-w-2xl mx-auto p-8 text-center"> import Button from '@/components/ui/button/Button.vue';
<h1 class="text-4xl sm:text-5xl font-extrabold mb-4"> import {
welcome to the 17th Stepper,
</h1> StepperDescription,
<p class=" mb-8"> StepperIndicator,
Welcome click below to get started. StepperItem,
</p> StepperSeparator,
StepperTitle,
StepperTrigger,
} from '@/components/ui/stepper'
import { useUserStore } from '@/stores/user';
import { Check, Circle, Dot, Users, X } from 'lucide-vue-next'
import { computed, ref } from 'vue';
import Application from './Application.vue';
<Button class="w-44" @click="goToLogin">Get started</Button> function goToLogin() {
const redirectUrl = encodeURIComponent(window.location.origin + '/join')
window.location.href = `https://aj17thdevapi.nexuszone.net/login?redirect=${redirectUrl}`;
}
let userStore = useUserStore();
const steps = computed(() => {
const isDenied = userStore.state === 'denied'
return [
{
step: 1,
title: 'Create account',
description: 'Begin by setting up your account',
},
{
step: 2,
title: 'Submit application',
description: 'Provide a few details about yourself',
},
{
step: 3,
title: 'Application review',
description: 'Our team will review your submission',
},
{
step: 4,
title: isDenied ? 'Application denied' : 'Acceptance',
description: isDenied
? 'Your application was not approved'
: 'Get started with the 17th Rangers',
},
]
})
const currentStep = computed<number>(() => {
if (!userStore.isLoggedIn)
return 1;
switch (userStore.state) {
case "guest":
return 2;
break;
case "applicant":
return 3;
break;
case "member":
return 5;
break;
case "denied":
return 5;
break;
}
})
const finalPanel = ref<'app' | 'message'>('message');
</script>
<template>
<div class="flex flex-col items-center mt-10 w-full">
<!-- Stepper Container -->
<div class="w-full flex justify-center">
<div class="w-full max-w-7xl">
<Stepper class="flex w-full items-start gap-2" v-model="currentStep">
<StepperItem v-for="step in steps" :key="step.step" v-slot="{ state }"
class="relative flex w-full flex-col items-center" :step="step.step">
<StepperSeparator v-if="step.step !== steps[steps.length - 1]?.step"
class="absolute left-[calc(50%+20px)] right-[calc(-50%+10px)] top-5 block h-0.5 rounded-full bg-muted group-data-[state=completed]:bg-primary" />
<StepperTrigger as-child>
<Button :variant="state === 'completed' || state === 'active' ? 'default' : 'outline'"
size="icon" class="z-10 rounded-full shrink-0"
:class="[state === 'active' && 'ring-2 ring-ring ring-offset-2 ring-offset-background']">
<template v-if="state === 'completed'">
<X v-if="step.step === 4 && userStore.state === 'denied'" class="size-5" />
<Check v-else class="size-5" />
</template>
<Circle v-if="state === 'active'" />
<Dot v-if="state === 'inactive'" />
</Button>
</StepperTrigger>
<div class="mt-2 flex flex-col items-center text-center">
<StepperTitle class="text-sm font-semibold transition lg:text-base"
:class="[state === 'active' && 'text-primary']">
{{ step.title }}
</StepperTitle>
<StepperDescription
class="sr-only text-xs text-muted-foreground transition md:not-sr-only lg:text-sm"
:class="[state === 'active' && 'text-primary']">
{{ step.description }}
</StepperDescription>
</div>
</StepperItem>
</Stepper>
</div>
</div>
<!-- Content -->
<div class="mt-12 mb-20 flex w-full max-w-6xl justify-center">
<div v-if="currentStep === 1" class="w-full max-w-2xl p-8">
<h1 class="text-3xl sm:text-4xl font-bold mb-4 text-left">
Create your account
</h1>
<p class="text-left text-muted-foreground mb-6">
You'll be redirected to our secure sign-in system to set up your account
and begin your application.
</p>
<Button class="px-6 py-3" @click="goToLogin">
Continue to account creation
</Button>
</div>
<Application v-else-if="currentStep === 2" @submit="userStore.loadUser()" :mode="'create'"></Application>
<Application v-else-if="currentStep === 3" :mode="'view-self'"></Application>
<div v-if="currentStep === 5" class="w-full p-8 pt-0">
<div class="mb-5">
<div class="flex w-min *:px-10 pt-2 border-b *:w-full *:text-center *:pb-1 *:cursor-pointer">
<label :class="finalPanel === 'message' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="finalPanel = 'message'">Message
</label>
<label :class="finalPanel === 'app' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="finalPanel = 'app'">Application
</label>
</div>
</div>
<div v-if="finalPanel === 'message'">
<!-- Accepted message -->
<div v-if="userStore.state === 'member'">
<h1 class="text-3xl sm:text-4xl font-bold mb-4 text-left">
Welcome to the 17th Ranger Battalion
</h1>
<div class="space-y-4 text-muted-foreground text-left leading-relaxed">
<p>
Your application to the 17th Ranger Battalion has been <strong>accepted</strong>!
Were excited to welcome you to the community.
</p>
<p>
There are just a couple of steps to complete before joining us on the battlefield:
</p>
<!-- MODPACK SECTION -->
<h2 class="text-xl font-semibold text-foreground mt-6">1. Download the Modpack</h2>
<p>
Youll need to download our private server modpack. This can take some time, so we
recommend
starting as soon as possible. The link below leads to our
<strong>Shadow Mod</strong>, which automatically pulls all required dependencies.
</p>
<ul class="list-disc pl-6 space-y-1">
<li>Subscribe to the Shadow Mod.</li>
<li>When prompted, choose <em>“Yes”</em> to download all associated mods.</li>
</ul>
<p>
<a href="https://www.guilded.gg/Iceberg-gaming/groups/v3j2vAP3/channels/6979335e-60f7-4ab9-9590-66df69367d1e/docs/2013948655"
class="text-primary underline" target="_blank">
Click here for the full installation guide
</a>
</p>
<!-- CONTACT SECTION -->
<h2 class="text-xl font-semibold text-foreground mt-6">2. Contact a Corporal or Higher</h2>
<p>
Once you have the modpack installed, connect on TeamSpeak or post in Discord. Anyone
with
the
rank of <strong>Corporal or above</strong> can help get you set up.
</p>
<ul class="list-none pl-0 space-y-1">
<li><strong>TeamSpeak:</strong><a href="ts3server://ts.iceberg-gaming.com"
class="text-primary underline"
target="_blank">ts3server://ts.iceberg-gaming.com</a>
</li>
<li>
<strong>Discord:</strong>
<a href="https://discord.gg/7hDQCEb" class="text-primary underline"
target="_blank">https://discord.gg/7hDQCEb</a>
</li>
</ul>
<p>
They will assist you with your initial assessments and training. Basic trainings run on
a
rotating schedule or can be requested through our Battalion Forms. Dont hesitate to hop
in
during weeknights or Saturday operations to start playing with us!
</p>
<!-- FINAL NOTES -->
<h2 class="text-xl font-semibold text-foreground mt-6">3. Get Familiar with the Unit</h2>
<p>
Please take a moment to read through our <strong>Code of Conduct</strong>,
<strong>Ranks</strong>, and <strong>Structure</strong> pages. We also encourage you to
browse
our forums and introduce yourself.
</p>
<p>
If you have any questions, feel free to reach out on TeamSpeak, Discord, or Guilded,
someone
will always be around to help.
</p>
</div>
</div>
<!-- Denied message -->
<div v-else-if="userStore.state === 'denied'">
<div class="w-full max-w-2xl p-8">
<h1 class="text-3xl sm:text-4xl font-bold mb-4 text-left text-destructive">
Application Not Approved
</h1>
<div class="space-y-4 text-muted-foreground text-left leading-relaxed">
<p>
Thank you for your interest in joining the <strong>17th Ranger Battalion</strong>.
After reviewing your application, we regret to inform you that we are not able to
approve it at this time.
</p>
<p>
If you would like more information, you are encouraged to
<strong>reach out and inquire</strong> about the reason your application was not
approved.
We are always happy to provide clarification where possible.
</p>
<p>
You are welcome to <strong>resubmit your application in the future</strong> should
your
circumstances change or when we may be better able to incorporate you into the unit.
</p>
<p>
All the best,<br />
<span class="text-foreground font-medium">The 17th Ranger Battalion Recruitment
Team</span>
</p>
</div>
</div>
</div>
</div>
<div v-if="finalPanel === 'app'">
<Application :mode="'view-self'"></Application>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup>
import Button from '@/components/ui/button/Button.vue';
function goToLogin() {
window.location.href = 'https://aj17thdevapi.nexuszone.net/login';
}
</script>

View File

@@ -10,9 +10,10 @@ import {
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import Button from '@/components/ui/button/Button.vue'; import Button from '@/components/ui/button/Button.vue';
import { onMounted, ref } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { CheckIcon, XIcon } from 'lucide-vue-next'; import { CheckIcon, XIcon } from 'lucide-vue-next';
import Application from './Application.vue';
const appList = ref([]); const appList = ref([]);
const now = Date.now(); const now = Date.now();
@@ -61,48 +62,113 @@ async function handleDeny(id) {
const router = useRouter(); const router = useRouter();
function openApplication(id) { function openApplication(id) {
router.push(`./application/${id}`) router.push(`/administration/applications/${id}`)
openPanel.value = true;
} }
function closeApplication() {
router.push('/administration/applications')
openPanel.value = false;
}
const route = useRoute();
watch(() => route.params.id, (newId) => {
if (newId === undefined) {
openPanel.value = false;
}
})
const openPanel = ref(false);
onMounted(async () => { onMounted(async () => {
appList.value = await getAllApplications(); appList.value = await getAllApplications();
//preload application
if (route.params.id != undefined) {
openApplication(route.params.id)
}
}) })
</script> </script>
<template> <template>
<div class="mx-auto max-w-5xl"> <div class="px-20 mx-auto max-w-[100rem] w-full flex mt-5 h-52 min-h-0 overflow-hidden">
<h1 class="my-4">Manage Applications</h1> <!-- application list -->
<Table> <div :class="openPanel == false ? 'w-full' : 'w-2/5'" class="pr-9">
<!-- <TableCaption>A list of your recent invoices.</TableCaption> --> <h1 class="scroll-m-20 text-2xl font-semibold tracking-tight">Manage Applications</h1>
<TableHeader> <Table>
<TableRow> <TableHeader>
<TableHead>User</TableHead> <TableRow>
<TableHead>Date Submitted</TableHead> <TableHead>User</TableHead>
<TableHead class="text-right">Status</TableHead> <TableHead>Date Submitted</TableHead>
</TableRow> <TableHead class="text-right">Status</TableHead>
</TableHeader> </TableRow>
<TableBody> </TableHeader>
<TableRow v-for="app in appList" :key="app.id" class="cursor-pointer" <TableBody class="overflow-y-auto scrollbar-themed">
:onClick="() => { openApplication(app.id) }"> <TableRow v-for="app in appList" :key="app.id" class="cursor-pointer"
<TableCell class="font-medium">{{ app.member_name }}</TableCell> :onClick="() => { openApplication(app.id) }">
<TableCell :title="formatExact(app.submitted_at)"> <TableCell class="font-medium">{{ app.member_name }}</TableCell>
{{ formatAgo(app.submitted_at) }} <TableCell :title="formatExact(app.submitted_at)">
</TableCell> {{ formatAgo(app.submitted_at) }}
<TableCell v-if="app.app_status === ApplicationStatus.Pending" class="inline-flex items-end gap-2"> </TableCell>
<Button variant="success" @click.stop="() => { handleApprove(app.id) }"> <TableCell v-if="app.app_status === ApplicationStatus.Pending"
<CheckIcon></CheckIcon> class="inline-flex items-end gap-2">
</Button> <Button variant="success" @click.stop="() => { handleApprove(app.id) }">
<Button variant="destructive" @click.stop="() => { handleDeny(app.id) }"> <CheckIcon></CheckIcon>
<XIcon></XIcon> </Button>
</Button> <Button variant="destructive" @click.stop="() => { handleDeny(app.id) }">
</TableCell> <XIcon></XIcon>
<TableCell class="text-right font-semibold" :class="[ </Button>
, </TableCell>
app.app_status === ApplicationStatus.Pending && 'text-yellow-500', <TableCell class="text-right font-semibold" :class="[
app.app_status === ApplicationStatus.Accepted && 'text-green-500', ,
app.app_status === ApplicationStatus.Denied && 'text-destructive' app.app_status === ApplicationStatus.Pending && 'text-yellow-500',
]">{{ app.app_status }}</TableCell> app.app_status === ApplicationStatus.Accepted && 'text-green-500',
</TableRow> app.app_status === ApplicationStatus.Denied && 'text-destructive'
</TableBody> ]">{{ app.app_status }}</TableCell>
</Table> </TableRow>
</TableBody>
</Table>
</div>
<div v-if="openPanel" class="pl-9 border-l w-3/5" :key="$route.params.id">
<div class="mb-5 flex justify-between">
<p class="scroll-m-20 text-2xl font-semibold tracking-tight"> Application</p>
<button @click="closeApplication()" class="cursor-pointer">
<XIcon></XIcon>
</button>
</div>
<div class="overflow-y-auto max-h-[80vh] h-full mt-5 scrollbar-themed">
<Application :mode="'view-recruiter'"></Application>
</div>
</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>

View File

@@ -68,7 +68,7 @@ onMounted(async () => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<div class="max-w-5xl mx-auto"> <div class="max-w-5xl w-full mx-auto">
Active Active
<div> <div>
<Card> <Card>

Some files were not shown because too many files have changed in this diff Show More