140 Commits
0.1.0 ... main

Author SHA1 Message Date
0a2c6785c6 Merge pull request 'LOA-Fixes' (#112) from LOA-Fixes into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m25s
Continuous Deployment / Update Deployment (push) Successful in 2m22s
Reviewed-on: #112
2025-12-16 18:16:34 -06:00
dac4de236b added pagination support for member LOA history 2025-12-16 19:14:08 -05:00
5e1351d033 Fixed performance issue with member search on LOA form 2025-12-16 19:07:42 -05:00
5f6c17361b Fixed active LOA banner not appearing after client state rework 2025-12-16 19:02:32 -05:00
eca4a75a6e updated admin LOA wording 2025-12-16 19:00:38 -05:00
9196a86570 fixed error preventing LOA form from providing user feedback on submit when used from admin panel 2025-12-16 19:00:27 -05:00
05e6030626 Merge pull request 'fixed bug that caused the latest application to be hidden' (#111) from Recruiter-fix into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m43s
Continuous Deployment / Update Deployment (push) Successful in 2m24s
Reviewed-on: #111
2025-12-16 17:26:55 -06:00
d01881f0af fixed bug that caused the latest application to be hidden 2025-12-16 18:28:04 -05:00
905a975327 Merge pull request 'application-spacing-fix' (#110) from application-spacing-fix into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m26s
Reviewed-on: #110
2025-12-16 10:08:17 -06:00
5659f053ba tweaked semantics for comment post public button 2025-12-16 11:03:45 -05:00
52b0e3e86d potential fix for an error on first time application view 2025-12-16 11:03:31 -05:00
e949e32189 updated form validation to catch scientific notation 2025-12-16 10:57:01 -05:00
e9fadc724e fixed bottom margin not applying and header scroll issue 2025-12-16 10:38:21 -05:00
7dc93c9802 Merge pull request 'Calendar-attendance-fix' (#108) from Calendar-attendance-fix into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m24s
Continuous Deployment / Update Deployment (push) Successful in 2m41s
Reviewed-on: #108
2025-12-16 01:31:28 -06:00
0969d96e82 removed nuisance logging 2025-12-16 02:32:05 -05:00
89ee1899e0 fixed user store references 2025-12-16 02:30:54 -05:00
754ddd11d4 fixed calendar attendance grouping 2025-12-16 02:18:01 -05:00
2902404d2e Merge pull request 'possible fix for login loop by removing hardcoded address' (#107) from Application-Flow-Fix into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m54s
Reviewed-on: #107
2025-12-16 00:58:13 -06:00
8006f4f737 possible fix for login loop by removing hardcoded address 2025-12-16 01:59:13 -05:00
f7f2d1be8b Merge pull request '69-Navbar-user' (#106) from 69-Navbar-user into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m27s
Reviewed-on: #106
2025-12-16 00:45:12 -06:00
5cbe0e6c7f overhauled client member management system 2025-12-16 01:45:45 -05:00
04ad7a8f14 removed nuisance logging again 2025-12-16 01:04:54 -05:00
cf37e5d01c Merge pull request '47-Training-Report-Input-Upgrade' (#105) from 47-Training-Report-Input-Upgrade into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m45s
Reviewed-on: #105
2025-12-16 00:00:04 -06:00
ee7d70bbe0 added descriptions to attendee options 2025-12-16 00:59:59 -05:00
9e469013c7 implemented pagination for training reports 2025-12-16 00:23:59 -05:00
627f6bfe3d overhauled training report fields to use new searchable system 2025-12-15 23:02:22 -05:00
94fac645af Merge pull request '83-LOA-Feedback' (#104) from 83-LOA-Feedback into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m42s
Reviewed-on: #104
2025-12-15 19:28:09 -06:00
1eef9145a4 implemented pagination, header polish, and new display fixes 2025-12-15 20:28:14 -05:00
fd94a5f261 added support for ended_at 2025-12-15 19:12:02 -05:00
2a0d7c2ff2 implemented expandable LOA details 2025-12-15 19:06:07 -05:00
8aaaea5ed0 updated button semantics 2025-12-15 17:28:04 -05:00
45aa59d54a Updated end/cancel button wording 2025-12-15 17:18:49 -05:00
cf8113000f added submit feedback to LOA form 2025-12-15 16:44:48 -05:00
90e7a925ec Merge pull request 'Exit calendar view during loading' (#103) from 99-Loading-Screens into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m4s
Reviewed-on: #103
2025-12-15 15:05:40 -06:00
10fea0982f Merge branch 'main' into 99-Loading-Screens 2025-12-15 15:05:34 -06:00
cdabd66986 Exit calendar view during loading 2025-12-15 16:06:35 -05:00
fd99ec73b3 Merge pull request '99-Loading-Screens' (#102) from 99-Loading-Screens into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m28s
Reviewed-on: #102
2025-12-15 15:04:03 -06:00
bc51ca1fcf added more loaders 2025-12-15 16:02:16 -05:00
826943c1a3 Updated application loader 2025-12-15 15:40:06 -05:00
8409d971c1 added loader to training reports 2025-12-15 15:38:43 -05:00
6e2edc0096 Merge pull request '100-recruitment-ui-fixes' (#101) from 100-recruitment-ui-fixes into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m31s
Reviewed-on: #101
2025-12-15 12:34:30 -06:00
1dfdb6bd0e Tweaked application sorting method Close #79 2025-12-15 13:31:04 -05:00
618c290318 Added visual feedback when application is approved 2025-12-15 13:14:30 -05:00
7f98d52634 fixed error on approve/deny applications 2025-12-15 13:12:48 -05:00
a73431f622 fixed error when opening application from recruiter view when sidepanel is closed 2025-12-15 12:47:00 -05:00
4670b568a5 fixed "military" checkbox not saving state during application 2025-12-15 12:37:02 -05:00
82c9681dfa Removed approve/deny buttons from the app list 2025-12-15 12:23:29 -05:00
34469ee5af Merge pull request 'account-claim' (#98) from account-claim into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m35s
Continuous Deployment / Update Deployment (push) Successful in 2m39s
Reviewed-on: #98
2025-12-14 21:52:22 -06:00
ca4bb9fe2d Merge branch 'main' into account-claim 2025-12-14 21:52:12 -06:00
a335ce862d fixed post account creation/claim flow. This may fix #93 2025-12-14 22:47:18 -05:00
b99d6653f8 disabled mega logging 2025-12-14 22:39:07 -05:00
a6002dadb5 implemented account claiming system 2025-12-14 22:33:10 -05:00
7ac83b532b removed nuisance logging 2025-12-14 22:31:08 -05:00
2ee769dfdb Merge commit '412001b1b4a85e0dea04f642319e19396955af95' into account-claim 2025-12-14 17:20:56 -05:00
b2209ef870 Merge pull request 'added favicon and site name' (#97) from #80-favico into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m19s
Reviewed-on: #97
2025-12-14 16:18:50 -06:00
ed9190b298 added favicon and site name 2025-12-14 17:19:53 -05:00
412001b1b4 Merge pull request 'cleaned up db resource leaks' (#96) from db-resource-leak-fix into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m25s
Reviewed-on: #96
2025-12-14 16:04:02 -06:00
011439fb19 cleaned up db resource leaks 2025-12-14 16:53:40 -05:00
81ae9ebea1 Merge pull request 'implemented new polling system for user account changes' (#95) from 81-user-state-polling into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m30s
Reviewed-on: #95
2025-12-14 15:45:24 -06:00
2a2c2e1cb5 implemented new polling system for user account changes 2025-12-14 16:46:02 -05:00
9b337e6813 Merge pull request 'adjusted members API rules to support public calendar' (#92) from #70-Calendar-visibility into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m15s
Reviewed-on: #92
2025-12-14 13:12:59 -06:00
0a2748f144 Merge branch 'main' into #70-Calendar-visibility 2025-12-14 13:12:50 -06:00
9c903509ed adjusted members API rules to support public calendar 2025-12-14 14:11:13 -05:00
134fcf32d3 fixed calendar router rules to allow public access 2025-12-14 14:05:44 -05:00
f46ffafa4b Merge pull request 'fixed calendar router rules to allow public access' (#91) from #70-Calendar-visibility into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m9s
Reviewed-on: #91
2025-12-14 13:04:35 -06:00
da6b4f46a7 Merge pull request 'Hid admin comment button when not in recruiter view' (#90) from #86-internal-comment-button-visibility-fix into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m24s
Reviewed-on: #90
2025-12-14 12:59:41 -06:00
5edce9b6fd Hid admin comment button when not in recruiter view 2025-12-14 14:00:31 -05:00
15810097bb Merge pull request 'full API ts transition and sentry not reporting fix' (#89) from Sentry-log-capture into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m27s
Reviewed-on: #89
2025-12-14 12:52:32 -06:00
7aaeab5243 full API ts transition and sentry not reporting fix 2025-12-14 13:52:32 -05:00
84392591d1 Merge pull request 'API-Security' (#87) from API-Security into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m22s
Reviewed-on: #87
2025-12-14 11:18:07 -06:00
af984cddbd Updated references of moved things 2025-12-14 12:19:16 -05:00
359fcbb412 Merge commit 'e45e08ad910b2631eb9adc63311a7d44867e4402' into API-Security 2025-12-14 12:18:37 -05:00
e45e08ad91 Merge pull request 'Profile-system' (#88) from Profile-system into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m41s
Reviewed-on: #88
2025-12-14 11:03:29 -06:00
0c3f166f71 Merge branch 'main' into Profile-system 2025-12-14 10:51:18 -06:00
9229475836 finished state and role based auth across the full API 2025-12-14 11:38:45 -05:00
b91ecacb60 implemented role and state based authorization 2025-12-13 17:01:50 -05:00
7c4e8d7db8 Implemented login requirement for most of the API 2025-12-13 14:25:39 -05:00
e7b73f9e73 Calendar Integration 2025-12-13 01:36:17 -05:00
533e315642 Application integration 2025-12-13 01:36:12 -05:00
93e8f3b3d2 LOA integration 2025-12-13 01:24:25 -05:00
82eb6b7bbf Added displayname and member card system 2025-12-13 01:21:07 -05:00
2ea355d9d8 fix tagging on release v2
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m26s
Continuous Deployment / Update Deployment (push) Successful in 2m19s
always check your quotes
2025-12-12 19:26:05 -06:00
4d19f26f01 fix tagging in workflow
Some checks failed
Continuous Integration / Update Development (push) Successful in 2m31s
Continuous Deployment / Update Deployment (push) Failing after 1m42s
apparently git pull doesn't properly fetch new tags on upstream, so call that fetch first
2025-12-12 16:16:48 -06:00
8aad3c67c7 added profile nav options 2025-12-12 12:53:14 -05:00
445c15b797 Merge pull request 'database-view-updates' (#67) from database-view-updates into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m17s
Continuous Deployment / Update Deployment (push) Successful in 2m10s
Reviewed-on: #67
2025-12-12 10:27:18 -06:00
406f61a612 Merge branch 'main' into database-view-updates 2025-12-12 10:24:01 -06:00
f7daa1bb19 Merge pull request 'application-dp-upgrade' (#66) from application-dp-upgrade into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m35s
Reviewed-on: #66
2025-12-12 10:16:56 -06:00
8a7ca9c2f9 Merge branch 'main' into application-dp-upgrade 2025-12-12 10:12:10 -06:00
1730714737 updated loa bookstack to new render system 2025-12-12 11:10:32 -05:00
8dc11ee34d made nested bookstack links open in new tab 2025-12-12 11:06:00 -05:00
2a841ebf27 fixed "view coc" button causing application submit 2025-12-12 10:56:07 -05:00
d7908570b2 improved bookstack rendering 2025-12-12 10:53:45 -05:00
629fd59a7c fixed scrolling behaviour on application management page 2025-12-12 10:53:33 -05:00
d0322dc62e added discord scope 2025-12-12 10:30:13 -05:00
333bf20d86 integrated CoC pull from bookstack 2025-12-12 00:39:19 -05:00
bd3c3564a9 Merge pull request 'Documentation' (#65) from Documentation into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m23s
Reviewed-on: #65
2025-12-11 22:00:45 -06:00
6c688a86ea Merge commit 'a6d54e0b73b0e388a96008400977db2715f214ce' into Documentation 2025-12-11 22:49:57 -05:00
26ccae9078 added arrow to indicate redirect
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m29s
2025-12-11 22:36:20 -05:00
dc88126b6c Added documents site link
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m36s
2025-12-11 22:26:33 -05:00
c74b5b280b placeholdered CoC getter until CoC page exists on bookstack 2025-12-11 22:05:46 -05:00
0a1704b60b Updated application acceptance system 2025-12-11 21:43:49 -05:00
97119dec97 added config_id reference to .env 2025-12-11 21:22:23 -05:00
a6d54e0b73 Merge pull request '#54-Application-tweaks' (#62) from #54-Application-tweaks into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m8s
Reviewed-on: #62
2025-12-11 19:54:02 -06:00
79d80be1bf Merge commit '0919997e0f90ec5463f63f470213acd7e39493af' into #54-Application-tweaks 2025-12-11 20:55:03 -05:00
0919997e0f Merge pull request 'LOA-Upgrades' (#64) from LOA-Upgrades into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m32s
Reviewed-on: #64
2025-12-11 19:50:14 -06:00
a5d4941d00 Merge branch 'main' into LOA-Upgrades 2025-12-11 19:49:55 -06:00
3835f22940 removed nuisance logging 2025-12-11 20:41:29 -05:00
f0ac2dca02 fixed an issue with a bad response on submitting LOA 2025-12-11 20:37:00 -05:00
710b24e5a7 added policy system and self LOA management 2025-12-11 20:28:49 -05:00
bcde81093d implemented LOA cancelling and extensioning 2025-12-11 19:08:24 -05:00
a3216ba5ab More bookstack integration stuff 2025-12-11 15:49:08 -05:00
7ab06b6a4c hooked up new LOA creation system 2025-12-11 12:06:00 -05:00
9d217aafaf improved robustness of time serialization system 2025-12-11 12:05:41 -05:00
87c472e98e fixed dialog scaling issue 2025-12-11 11:52:11 -05:00
dcb4720129 placeholdered bookstack integration 2025-12-11 11:38:11 -05:00
8cdbb99d6f implemented constraints on datepickers 2025-12-11 11:25:09 -05:00
2821bc62c4 removed test datepicker 2025-12-11 11:00:11 -05:00
dd472a5283 updated datepicker dependency 2025-12-11 11:00:02 -05:00
92c0d657ea overhauled form system to new modern form 2025-12-11 10:25:29 -05:00
7a6020febb Changed event_date to start_date 2025-12-11 09:22:44 -06:00
fb64b35807 Updated database reference view_member_rank_status_all to view_member_rank_unit_status_latest 2025-12-10 22:18:52 -06:00
a8165e2ae5 Added Unit Table Header and Cell 2025-12-10 22:18:08 -06:00
79cf77dc63 Added 2 data types unit, unit_date 2025-12-10 22:17:45 -06:00
62defe5b6d LOA backend refactor 2025-12-10 21:30:41 -05:00
065d3f330a Merge pull request 'Updated GUILDED references and links.' (#60) from external-links-and-app-messages into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 1m56s
Continuous Deployment / Update Deployment (push) Successful in 2m9s
Reviewed-on: #60
2025-12-10 20:21:32 -06:00
dcd1f522ad Merge branch 'main' into external-links-and-app-messages 2025-12-10 20:21:24 -06:00
22c909cfa5 increase logging in deploy script
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m24s
for some reason it seems to not be fully updating the local repo, so increase logging and double pull
2025-12-10 20:20:06 -06:00
5354fa85f1 minor wording change to submit buttons
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m28s
2025-12-09 17:02:55 -05:00
f5a0df7795 Supported public vs internal application comments, and moved some type dependencies to the shared lib 2025-12-09 17:02:39 -05:00
e22f164097 Handled retired behaviour on join page
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m21s
2025-12-08 22:32:07 -05:00
ae9c7c89b1 fix acknowledge spelling
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m23s
2025-12-08 22:06:04 -05:00
dab0a7543c added steam regex support 2025-12-08 22:03:11 -05:00
63267ac679 set up viewing of users application history
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m22s
2025-12-08 21:28:02 -05:00
4a65596283 tweaked get app query to support multiple applications
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m23s
2025-12-08 19:29:59 -05:00
9720ee6ada Updated GUILDED references and links.
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m22s
2025-12-08 17:55:33 -06:00
e61bd1c5a1 Enabled restarting your application from denied state
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m23s
2025-12-08 17:10:53 -05:00
df89d9bf67 fixed some missing awaits 2025-12-08 16:24:00 -05:00
4ab803ec72 Fixed hardcoded value in application comment poster 2025-12-08 16:15:34 -05:00
6a55846f19 improved error message readability 2025-12-08 15:21:14 -05:00
f6c4c3e508 Added documentation page
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m13s
2025-12-04 22:24:12 -05:00
88 changed files with 3858 additions and 988 deletions

View File

@@ -46,8 +46,14 @@ jobs:
- name: Update Application Code - name: Update Application Code
run: | run: |
cd /var/www/html/milsim-site-v4 cd /var/www/html/milsim-site-v4
version=`git log -1 --format=%H`
echo "Current Revision: $version"
echo "Updating to: ${{ github.sha }}"
sudo -u nginx git reset --hard sudo -u nginx git reset --hard
sudo -u nginx git fetch --tags
sudo -u nginx git pull origin main sudo -u nginx git pull origin main
new_version=`git log -1 --format=%H`
echo "Successfully updated to: $new_version"
- name: Update Shared Dependencies and Fix Permissions - name: Update Shared Dependencies and Fix Permissions
run: | run: |

View File

@@ -21,7 +21,13 @@ CLIENT_URL= # This is whatever URL the client web app is served on
CLIENT_DOMAIN= #whatever.com CLIENT_DOMAIN= #whatever.com
APPLICATION_VERSION= # Should match release tag APPLICATION_VERSION= # Should match release tag
APPLICATION_ENVIRONMENT= # dev / prod APPLICATION_ENVIRONMENT= # dev / prod
CONFIG_ID= # configures
# Glitchtip # Glitchtip
GLITCHTIP_DSN= GLITCHTIP_DSN=
DISABLE_GLITCHTIP= # true/false DISABLE_GLITCHTIP= # true/false
# Bookstack
DOC_HOST= # https://bookstack.whatever.com/
DOC_TOKEN_SECRET=
DOC_TOKEN_ID=

View File

@@ -1,11 +1,15 @@
const dotenv = require('dotenv') import dotenv = require('dotenv');
dotenv.config(); dotenv.config();
const express = require('express') import express = require('express');
const cors = require('cors') import cors = require('cors');
const morgan = require('morgan') import morgan = require('morgan');
const app = express() const app = express()
app.use(morgan('dev')) app.use(morgan('dev', {
skip: (req) => {
return req.path === '/members/me';
}
}))
app.use(cors({ app.use(cors({
origin: [process.env.CLIENT_URL], // your SPA origins origin: [process.env.CLIENT_URL], // your SPA origins
@@ -19,7 +23,7 @@ app.set('trust proxy', 1);
const port = process.env.SERVER_PORT; const port = process.env.SERVER_PORT;
//glitchtip setup //glitchtip setup
const sentry = require('@sentry/node'); import sentry = require('@sentry/node');
if (process.env.DISABLE_GLITCHTIP === "true") { if (process.env.DISABLE_GLITCHTIP === "true") {
console.log("Glitchtip disabled") console.log("Glitchtip disabled")
} else { } else {
@@ -27,14 +31,14 @@ if (process.env.DISABLE_GLITCHTIP === "true") {
let release = process.env.APPLICATION_VERSION; let release = process.env.APPLICATION_VERSION;
let environment = process.env.APPLICATION_ENVIRONMENT; let environment = process.env.APPLICATION_ENVIRONMENT;
console.log(release, environment) console.log(release, environment)
sentry.init({ dsn: dsn, release: release, environment: environment }); sentry.init({ dsn: dsn, release: release, environment: environment, integrations: [sentry.captureConsoleIntegration({ levels: ['error'] })] });
console.log("Glitchtip initialized"); console.log("Glitchtip initialized");
} }
//session setup //session setup
const path = require('path') import path = require('path');
const session = require('express-session') import session = require('express-session');
const passport = require('passport') import passport = require('passport');
const SQLiteStore = require('connect-sqlite3')(session); const SQLiteStore = require('connect-sqlite3')(session);
app.use(session({ app.use(session({
@@ -51,23 +55,21 @@ app.use(session({
app.use(passport.authenticate('session')); app.use(passport.authenticate('session'));
// Mount route modules // Mount route modules
const applicationsRouter = require('./routes/applications'); import { applicationRouter } from './routes/applications';
const { memberRanks, ranks } = require('./routes/ranks'); import { memberRanks, ranks } from './routes/ranks';
const members = require('./routes/members'); import { memberRouter } from './routes/members';
const loaHandler = require('./routes/loa') import { loaRouter } from './routes/loa';
const { status, memberStatus } = require('./routes/statuses') import { status, memberStatus } from './routes/statuses';
const authRouter = require('./routes/auth') import { authRouter } from './routes/auth';
const { roles, memberRoles } = require('./routes/roles'); import { roles, memberRoles } from './routes/roles';
const { courseRouter, eventRouter } = require('./routes/course'); import { courseRouter, eventRouter } from './routes/course';
const { calendarRouter } = require('./routes/calendar') import { calendarRouter } from './routes/calendar';
const morgan = require('morgan');
const { env } = require('process');
app.use('/application', applicationsRouter); app.use('/application', applicationRouter);
app.use('/ranks', ranks); app.use('/ranks', ranks);
app.use('/memberRanks', memberRanks); app.use('/memberRanks', memberRanks);
app.use('/members', members); app.use('/members', memberRouter);
app.use('/loa', loaHandler); app.use('/loa', loaRouter);
app.use('/status', status) app.use('/status', status)
app.use('/memberStatus', memberStatus) app.use('/memberStatus', memberStatus)
app.use('/roles', roles) app.use('/roles', roles)

View File

@@ -0,0 +1,49 @@
import { MemberState } from "@app/shared/types/member";
import { NextFunction, Request, Response } from "express";
import { stat } from "fs";
export const requireLogin = function (req: Request, res: Response, next: NextFunction) {
if (req.user?.id)
next();
else
res.sendStatus(401)
}
export function requireMemberState(state: MemberState) {
return function (req: Request, res: Response, next: NextFunction) {
if (req.user?.state === state)
next();
else
res.status(403).send(`You must be a ${state} of the 17th RBN to access this resource`);
}
}
export function requireRole(requiredRoles: string | string[]) {
// Normalize the input to always be an array of lowercase required roles
const normalizedRequiredRoles: string[] = Array.isArray(requiredRoles)
? requiredRoles.map(role => role.toLowerCase())
: [requiredRoles.toLowerCase()];
const DEV_ROLE = 'dev';
return function (req: Request, res: Response, next: NextFunction) {
if (!req.user || !req.user.roles) {
// User is not authenticated or has no roles array
return res.sendStatus(401);
}
const userRolesLowercase = req.user.roles.map(role => role.name.toLowerCase());
// Check if the user has *any* of the required roles OR the 'dev' role
const hasAccess = userRolesLowercase.some(userRole =>
userRole === DEV_ROLE || normalizedRequiredRoles.includes(userRole)
);
if (hasAccess) {
return next();
} else {
// User is authenticated but does not have the necessary permissions
return res.sendStatus(403);
}
};
}

View File

@@ -2,32 +2,54 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
import pool from '../db'; import pool from '../db';
import { approveApplication, createApplication, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService'; import { approveApplication, createApplication, denyApplication, getAllMemberApplications, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService';
import { MemberState, setUserState } from '../services/memberService'; import { setUserState } from '../services/memberService';
import { MemberState } from '@app/shared/types/member';
import { getRankByName, insertMemberRank } from '../services/rankService'; import { getRankByName, insertMemberRank } from '../services/rankService';
import { ApplicationFull, CommentRow } from "@app/shared/types/application" import { ApplicationFull, CommentRow } from "@app/shared/types/application"
import { assignUserToStatus } from '../services/statusService'; import { assignUserToStatus } from '../services/statusService';
import { Request, response, Response } from 'express';
import { getUserRoles } from '../services/rolesService';
import { requireLogin, requireRole } from '../middleware/auth';
//get CoC
router.get('/coc', async (req: Request, res: Response) => {
const output = await fetch(`${process.env.DOC_HOST}/api/pages/714`, {
headers: {
Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`,
}
})
if (output.ok) {
const out = await output.json();
res.status(200).json(out.html);
} else {
console.error("Failed to fetch LOA policy from bookstack");
res.sendStatus(500);
}
})
// POST /application // POST /application
router.post('/', async (req, res) => { router.post('/', [requireLogin], async (req, res) => {
try { try {
const App = req.body?.App || {}; const App = req.body?.App || {};
const memberID = req.user.id; const memberID = req.user.id;
const appVersion = 1; const appVersion = 1;
createApplication(memberID, appVersion, JSON.stringify(App)) await createApplication(memberID, appVersion, JSON.stringify(App))
setUserState(memberID, MemberState.Applicant); await setUserState(memberID, MemberState.Applicant);
res.sendStatus(201); res.sendStatus(201);
} catch (err) { } catch (err) {
console.error('Insert failed:', err); console.error('Failed to create application: \n', err);
res.status(500).json({ error: 'Failed to save application' }); res.status(500).json({ error: 'Failed to create application' });
} }
}); });
// GET /application/all // GET /application/all
router.get('/all', async (req, res) => { router.get('/all', [requireLogin, requireRole("Recruiter")], async (req, res) => {
try { try {
const rows = await getApplicationList(); const rows = await getApplicationList();
res.status(200).json(rows); res.status(200).json(rows);
@@ -37,7 +59,21 @@ router.get('/all', async (req, res) => {
} }
}); });
router.get('/me', async (req, res) => { router.get('/meList', async (req, res) => {
let userID = req.user.id;
try {
let application = await getAllMemberApplications(userID);
return res.status(200).json(application);
} catch (error) {
console.error('Failed to load applications: \n', error);
return res.status(500).json(error);
}
})
router.get('/me', [requireLogin], async (req, res) => {
let userID = req.user.id; let userID = req.user.id;
@@ -62,13 +98,17 @@ router.get('/me', async (req, res) => {
}) })
// GET /application/:id // GET /application/:id
router.get('/:id', async (req, res) => { router.get('/me/:id', [requireLogin], async (req: Request, res: Response) => {
let appID = req.params.id; let appID = Number(req.params.id);
console.log("HELLO") let member = req.user.id;
try { try {
const application = await getApplicationByID(appID); const application = await getApplicationByID(appID);
if (application === undefined) if (application === undefined)
return res.sendStatus(204); return res.sendStatus(204);
console.log(application.member_id, member)
if (application.member_id != member) {
return res.sendStatus(403);
}
const comments: CommentRow[] = await getApplicationComments(appID); const comments: CommentRow[] = await getApplicationComments(appID);
@@ -84,30 +124,44 @@ router.get('/:id', async (req, res) => {
} }
}); });
// GET /application/:id
router.get('/:id', [requireLogin, requireRole("Recruiter")], async (req: Request, res: Response) => {
let appID = Number(req.params.id);
let asAdmin = !!req.query.admin || false;
try {
const application = await getApplicationByID(appID);
if (application === undefined)
return res.sendStatus(204);
const comments: CommentRow[] = await getApplicationComments(appID, asAdmin);
const output: ApplicationFull = {
application,
comments,
}
return res.status(200).json(output);
}
catch (err) {
console.error('Query failed:', err);
return res.status(500).json({ error: 'Failed to load application' });
}
});
// POST /application/approve/:id // POST /application/approve/:id
router.post('/approve/:id', async (req, res) => { router.post('/approve/:id', [requireLogin, requireRole("Recruiter")], async (req: Request, res: Response) => {
const appID = req.params.id; const appID = Number(req.params.id);
const approved_by = req.user.id;
try { try {
const app = await getApplicationByID(appID); const app = await getApplicationByID(appID);
const result = await approveApplication(appID); await approveApplication(appID, approved_by);
console.log("START");
console.log(app, result);
//guard against failures
if (result.affectedRows != 1) {
throw new Error("Something went wrong approving the application");
}
console.log(app.member_id);
//update user profile //update user profile
await setUserState(app.member_id, MemberState.Member); await setUserState(app.member_id, MemberState.Member);
let nextRank = await getRankByName('Recruit') await pool.query('CALL sp_accept_new_recruit_validation(?, ?, ?, ?)', [Number(process.env.CONFIG_ID), app.member_id, approved_by, approved_by])
await insertMemberRank(app.member_id, nextRank.id);
//assign user to "pending basic"
await assignUserToStatus(app.member_id, 1);
res.sendStatus(200); res.sendStatus(200);
} catch (err) { } catch (err) {
console.error('Approve failed:', err); console.error('Approve failed:', err);
@@ -116,29 +170,15 @@ router.post('/approve/:id', async (req, res) => {
}); });
// POST /application/deny/:id // POST /application/deny/:id
router.post('/deny/:id', async (req, res) => { router.post('/deny/:id', [requireLogin, requireRole("Recruiter")], async (req: Request, res: Response) => {
const appID = req.params.id; const appID = Number(req.params.id);
const approver = Number(req.user.id);
const sql = `
UPDATE applications
SET denied_at = NOW()
WHERE id = ?
AND approved_at IS NULL
AND denied_at IS NULL
`;
try { try {
const result = await pool.execute(sql, appID); const app = await getApplicationByID(appID);
await denyApplication(appID, approver);
console.log(result); await setUserState(app.member_id, MemberState.Denied);
res.sendStatus(200);
if (result.affectedRows === 0) {
res.status(400).json('Something went wrong denying the application');
}
if (result.affectedRows == 1) {
res.sendStatus(200);
}
} catch (err) { } catch (err) {
console.error('Approve failed:', err); console.error('Approve failed:', err);
res.status(500).json({ error: 'Failed to deny application' }); res.status(500).json({ error: 'Failed to deny application' });
@@ -146,10 +186,12 @@ router.post('/deny/:id', async (req, res) => {
}); });
// POST /application/:id/comment // POST /application/:id/comment
router.post('/:id/comment', async (req, res) => { router.post('/:id/comment', [requireLogin], async (req: Request, res: Response) => {
const appID = req.params.id; const appID = req.params.id;
const data = req.body.message; const data = req.body.message;
const user = 1; const user = req.user;
console.log(user)
const sql = `INSERT INTO application_comments( const sql = `INSERT INTO application_comments(
application_id, application_id,
@@ -158,10 +200,11 @@ router.post('/:id/comment', async (req, res) => {
) )
VALUES(?, ?, ?);` VALUES(?, ?, ?);`
try {
const conn = await pool.getConnection();
const result = await conn.query(sql, [appID, user, data]) try {
var conn = await pool.getConnection();
const result = await conn.query(sql, [appID, user.id, data])
console.log(result) console.log(result)
if (result.affectedRows !== 1) { if (result.affectedRows !== 1) {
conn.release(); conn.release();
@@ -183,7 +226,67 @@ VALUES(?, ?, ?);`
} catch (err) { } catch (err) {
console.error('Comment failed:', err); console.error('Comment failed:', err);
res.status(500).json({ error: 'Could not post comment' }); res.status(500).json({ error: 'Could not post comment' });
} finally {
conn.release();
} }
}); });
module.exports = router; // POST /application/:id/comment
router.post('/:id/adminComment', [requireLogin, requireRole("Recruiter")], async (req: Request, res: Response) => {
const appID = req.params.id;
const data = req.body.message;
const user = req.user;
console.log(user)
const sql = `INSERT INTO application_comments(
application_id,
poster_id,
post_content,
admin_only
)
VALUES(?, ?, ?, 1);`
try {
var conn = await pool.getConnection();
const result = await conn.query(sql, [appID, user.id, data])
console.log(result)
if (result.affectedRows !== 1) {
conn.release();
throw new Error("Insert Failure")
}
const getSQL = `SELECT app.id AS comment_id,
app.post_content,
app.poster_id,
app.post_time,
app.last_modified,
app.admin_only,
member.name AS poster_name
FROM application_comments AS app
INNER JOIN members AS member ON member.id = app.poster_id
WHERE app.id = ?; `;
const comment = await conn.query(getSQL, [result.insertId])
res.status(201).json(comment[0]);
} catch (err) {
console.error('Comment failed:', err);
res.status(500).json({ error: 'Could not post comment' });
} finally {
conn.release();
}
});
router.post('/restart', async (req: Request, res: Response) => {
const user = req.user.id;
try {
await setUserState(user, MemberState.Guest);
res.sendStatus(200);
} catch (error) {
console.error('Comment failed:', error);
res.status(500).json({ error: 'Could not rester application' });
}
})
export const applicationRouter = router;

View File

@@ -6,9 +6,18 @@ dotenv.config();
const express = require('express'); const express = require('express');
const { param } = require('./applications'); const { param } = require('./applications');
const router = express.Router(); const router = express.Router();
import { Role } from '@app/shared/types/roles';
import pool from '../db'; import pool from '../db';
import { requireLogin } from '../middleware/auth';
import { getUserRoles } from '../services/rolesService';
import { getUserState, mapDiscordtoID } from '../services/memberService';
import { MemberState } from '@app/shared/types/member';
import { toDateTime } from '@app/shared/utils/time';
const querystring = require('querystring'); const querystring = require('querystring');
function parseJwt(token) {
return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
}
passport.use(new OpenIDConnectStrategy({ passport.use(new OpenIDConnectStrategy({
issuer: process.env.AUTH_ISSUER, issuer: process.env.AUTH_ISSUER,
@@ -18,37 +27,56 @@ passport.use(new OpenIDConnectStrategy({
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,
scope: ['openid', 'profile'] scope: ['openid', 'profile', 'discord']
}, async function verify(issuer, sub, profile, jwtClaims, accessToken, refreshToken, params, cb) { }, async function verify(issuer, sub, profile, jwtClaims, accessToken, refreshToken, params, cb) {
console.log('--- OIDC verify() called ---'); // console.log('--- OIDC verify() called ---');
console.log('issuer:', issuer); // console.log('issuer:', issuer);
console.log('sub:', sub); // console.log('sub:', sub);
// console.log('profile:', JSON.stringify(profile, null, 2)); // // console.log('discord:', discord);
console.log('profile:', profile); // console.log('profile:', profile);
console.log('id_token claims:', JSON.stringify(jwtClaims, null, 2)); // console.log('jwt: ', parseJwt(jwtClaims));
console.log('preferred_username:', jwtClaims?.preferred_username); // console.log('params:', params);
const con = await pool.getConnection();
try { try {
var con = await pool.getConnection();
await con.beginTransaction(); await con.beginTransaction();
//lookup existing user //lookup existing user
const existing = await con.query(`SELECT id FROM members WHERE authentik_issuer = ? AND authentik_sub = ? LIMIT 1;`, [issuer, sub]); const existing = await con.query(`SELECT id FROM members WHERE authentik_issuer = ? AND authentik_sub = ? LIMIT 1;`, [issuer, sub]);
let memberId; let memberId: number;
//if member exists //if member exists
if (existing.length > 0) { if (existing.length > 0) {
memberId = existing[0].id; memberId = existing[0].id;
} else { } else {
//otherwise: create account //otherwise: create account
const username = sub.username; const jwt = parseJwt(jwtClaims);
const discordID = jwt.discord.id as number;
const result = await con.query( //check if account is available to claim
`INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`, memberId = await mapDiscordtoID(discordID);
[username, sub, issuer]
) if (memberId === null) {
memberId = Number(result.insertId); // create new account
const username = sub.username;
const result = await con.query(
`INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`,
[username, sub, issuer]
)
memberId = Number(result.insertId);
} else {
// claim existing account
const result = await con.query(
`UPDATE members SET authentik_sub = ?, authentik_issuer = ? WHERE id = ?;`,
[sub, issuer, memberId]
)
}
} }
await con.query(`UPDATE members SET last_login = ? WHERE id = ?`, [toDateTime(new Date()), memberId])
await con.commit(); await con.commit();
return cb(null, { memberId }); return cb(null, { memberId });
} catch (error) { } catch (error) {
@@ -66,12 +94,6 @@ router.get('/login', (req, res, next) => {
next(); next();
}, passport.authenticate('openidconnect')); }, passport.authenticate('openidconnect'));
// router.get('/callback', (req, res, next) => {
// passport.authenticate('openidconnect', {
// successRedirect: req.session.redirectTo,
// failureRedirect: process.env.CLIENT_URL
// })
// });
router.get('/callback', (req, res, next) => { router.get('/callback', (req, res, next) => {
const redirectURI = req.session.redirectTo; const redirectURI = req.session.redirectTo;
@@ -90,7 +112,7 @@ router.get('/callback', (req, res, next) => {
})(req, res, next); })(req, res, next);
}); });
router.get('/logout', function (req, res, next) { router.get('/logout', [requireLogin], 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 = {
@@ -110,15 +132,17 @@ 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 as number;
const con = await pool.getConnection();
var userData; var userData: { id: number, name: string, roles: Role[], state: MemberState };
try { try {
var con = await pool.getConnection();
let userResults = await con.query(`SELECT id, name FROM members WHERE id = ?;`, [memberID]) let userResults = await con.query(`SELECT id, name FROM members WHERE id = ?;`, [memberID])
userData = userResults[0]; userData = userResults[0];
let userRoles = await getUserRoles(memberID);
userData.roles = userRoles || [];
userData.state = await getUserState(memberID);
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally { } finally {
@@ -128,5 +152,18 @@ passport.deserializeUser(function (user, cb) {
}); });
}); });
declare global {
namespace Express {
interface Request {
user: {
id: number;
name: string;
roles: Role[];
state: MemberState;
};
}
}
}
module.exports = router;
export const authRouter = router;

View File

@@ -1,6 +1,8 @@
import { Request, Response } from "express"; import { Request, Response } from "express";
import { createEvent, getEventAttendance, getEventDetails, getShortEventsInRange, setAttendanceStatus, setEventCancelled, updateEvent } from "../services/calendarService"; import { createEvent, getEventAttendance, getEventDetails, getShortEventsInRange, setAttendanceStatus, setEventCancelled, updateEvent } from "../services/calendarService";
import { CalendarAttendance, CalendarEvent } from "@app/shared/types/calendar"; import { CalendarAttendance, CalendarEvent } from "@app/shared/types/calendar";
import { requireLogin, requireMemberState, requireRole } from "../middleware/auth";
import { MemberState } from "@app/shared/types/member";
const express = require('express'); const express = require('express');
const r = express.Router(); const r = express.Router();
@@ -35,7 +37,7 @@ r.get('/upcoming', async (req, res) => {
res.sendStatus(501); res.sendStatus(501);
}) })
r.post('/:id/cancel', async (req: Request, res: Response) => { r.post('/:id/cancel', [requireLogin, requireMemberState(MemberState.Member)], async (req: Request, res: Response) => {
try { try {
const eventID = Number(req.params.id); const eventID = Number(req.params.id);
setEventCancelled(eventID, true); setEventCancelled(eventID, true);
@@ -45,7 +47,7 @@ r.post('/:id/cancel', async (req: Request, res: Response) => {
res.status(500).send('Error setting cancel status'); res.status(500).send('Error setting cancel status');
} }
}) })
r.post('/:id/uncancel', async (req: Request, res: Response) => { r.post('/:id/uncancel', [requireLogin, requireMemberState(MemberState.Member)], async (req: Request, res: Response) => {
try { try {
const eventID = Number(req.params.id); const eventID = Number(req.params.id);
setEventCancelled(eventID, false); setEventCancelled(eventID, false);
@@ -57,7 +59,7 @@ r.post('/:id/uncancel', async (req: Request, res: Response) => {
}) })
r.post('/:id/attendance', async (req: Request, res: Response) => { r.post('/:id/attendance', [requireLogin, requireMemberState(MemberState.Member)], async (req: Request, res: Response) => {
try { try {
let member = req.user.id; let member = req.user.id;
let event = Number(req.params.id); let event = Number(req.params.id);
@@ -85,7 +87,7 @@ r.get('/:id', async (req: Request, res: Response) => {
//post a new calendar event //post a new calendar event
r.post('/', async (req: Request, res: Response) => { r.post('/', [requireLogin, requireMemberState(MemberState.Member)], async (req: Request, res: Response) => {
try { try {
const member = req.user.id; const member = req.user.id;
let event: CalendarEvent = req.body; let event: CalendarEvent = req.body;
@@ -100,7 +102,7 @@ r.post('/', async (req: Request, res: Response) => {
} }
}) })
r.put('/', async (req: Request, res: Response) => { r.put('/', [requireLogin, requireMemberState(MemberState.Member)], async (req: Request, res: Response) => {
try { try {
let event: CalendarEvent = req.body; let event: CalendarEvent = req.body;
event.start = new Date(event.start); event.start = new Date(event.start);
@@ -114,5 +116,4 @@ r.put('/', async (req: Request, res: Response) => {
} }
}) })
export const calendarRouter = r;
module.exports.calendarRouter = r;

View File

@@ -1,11 +1,18 @@
import { CourseAttendee, CourseEventDetails } from "@app/shared/types/course"; import { CourseAttendee, CourseEventDetails } from "@app/shared/types/course";
import { getAllCourses, getCourseEventAttendees, getCourseEventDetails, getCourseEventRoles, getCourseEvents, insertCourseEvent } from "../services/CourseSerivce"; import { getAllCourses, getCourseEventAttendees, getCourseEventDetails, getCourseEventRoles, getCourseEvents, insertCourseEvent } from "../services/CourseSerivce";
import { Request, Response, Router } from "express"; import { Request, Response, Router } from "express";
import { requireLogin, requireMemberState } from "../middleware/auth";
import { MemberState } from "@app/shared/types/member";
const courseRouter = Router(); const cr = Router();
const eventRouter = Router(); const er = Router();
courseRouter.get('/', async (req, res) => { cr.use(requireLogin)
er.use(requireLogin)
cr.use(requireMemberState(MemberState.Member))
er.use(requireMemberState(MemberState.Member))
cr.get('/', async (req, res) => {
try { try {
const courses = await getAllCourses(); const courses = await getAllCourses();
res.status(200).json(courses); res.status(200).json(courses);
@@ -15,7 +22,7 @@ courseRouter.get('/', async (req, res) => {
} }
}) })
courseRouter.get('/roles', async (req, res) => { cr.get('/roles', async (req, res) => {
try { try {
const roles = await getCourseEventRoles(); const roles = await getCourseEventRoles();
res.status(200).json(roles); res.status(200).json(roles);
@@ -25,24 +32,27 @@ courseRouter.get('/roles', async (req, res) => {
} }
}) })
eventRouter.get('/', async (req: Request, res: Response) => { er.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 { try {
let events = await getCourseEvents(sortDir, search); const allowedSorts = new Map([
["ascending", "ASC"],
["descending", "DESC"]
]);
const page = Number(req.query.page) || undefined;
const pageSize = Number(req.query.pageSize) || undefined;
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);
let events = await getCourseEvents(sortDir, search, page, pageSize);
res.status(200).json(events); res.status(200).json(events);
} catch (error) { } catch (error) {
console.error('failed to fetch reports', error); console.error('failed to fetch reports', error);
@@ -50,7 +60,7 @@ eventRouter.get('/', async (req: Request, res: Response) => {
} }
}); });
eventRouter.get('/:id', async (req: Request, res: Response) => { er.get('/:id', async (req: Request, res: Response) => {
try { try {
let out = await getCourseEventDetails(Number(req.params.id)); let out = await getCourseEventDetails(Number(req.params.id));
res.status(200).json(out); res.status(200).json(out);
@@ -60,7 +70,7 @@ eventRouter.get('/:id', async (req: Request, res: Response) => {
} }
}); });
eventRouter.get('/attendees/:id', async (req: Request, res: Response) => { er.get('/attendees/:id', async (req: Request, res: Response) => {
try { try {
const attendees: CourseAttendee[] = await getCourseEventAttendees(Number(req.params.id)); const attendees: CourseAttendee[] = await getCourseEventAttendees(Number(req.params.id));
res.status(200).json(attendees); res.status(200).json(attendees);
@@ -70,7 +80,7 @@ eventRouter.get('/attendees/:id', async (req: Request, res: Response) => {
} }
}) })
eventRouter.post('/', async (req: Request, res: Response) => { er.post('/', async (req: Request, res: Response) => {
const posterID: number = req.user.id; const posterID: number = req.user.id;
try { try {
console.log(); console.log();
@@ -85,5 +95,5 @@ eventRouter.post('/', async (req: Request, res: Response) => {
} }
}) })
module.exports.courseRouter = courseRouter; export const courseRouter = cr;
module.exports.eventRouter = eventRouter; export const eventRouter = er;

View File

@@ -1,56 +0,0 @@
const express = require('express');
const router = express.Router();
import pool from '../db';
//post a new LOA
router.post("/", async (req, res) => {
const { member_id, filed_date, start_date, end_date, reason } = req.body;
if (!member_id || !filed_date || !start_date || !end_date) {
return res.status(400).json({ error: "Missing required fields" });
}
try {
const result = await pool.query(
`INSERT INTO leave_of_absences
(member_id, filed_date, start_date, end_date, reason)
VALUES (?, ?, ?, ?, ?)`,
[member_id, filed_date, start_date, end_date, reason]
);
res.sendStatus(201);
} catch (error) {
console.error(error);
res.status(500).send('Something went wrong', error);
}
});
//get my current LOA
router.get("/me", async (req, res) => {
//TODO: implement current user getter
const user = 89;
try {
const result = await pool.query("SELECT * FROM leave_of_absences WHERE member_id = ?", [user])
res.status(200).json(result)
} catch (error) {
console.error(error);
res.status(500).send(error);
}
})
router.get('/all', async (req, res) => {
try {
const result = await pool.query(
`SELECT loa.*, members.name
FROM leave_of_absences AS loa
INNER JOIN members ON loa.member_id = members.id;
`);
res.status(200).json(result)
} catch (error) {
console.error(error);
res.status(500).send(error);
}
})
module.exports = router;

157
api/src/routes/loa.ts Normal file
View File

@@ -0,0 +1,157 @@
const express = require('express');
const router = express.Router();
import { Request, Response } from 'express';
import pool from '../db';
import { closeLOA, createNewLOA, getAllLOA, getLOAbyID, getLoaTypes, getUserLOA, setLOAExtension } from '../services/loaService';
import { LOARequest } from '@app/shared/types/loa';
import { requireLogin, requireRole } from '../middleware/auth';
router.use(requireLogin);
//member posts LOA
router.post("/", async (req: Request, res: Response) => {
let LOARequest = req.body as LOARequest;
LOARequest.member_id = req.user.id;
LOARequest.created_by = req.user.id;
LOARequest.filed_date = new Date();
try {
await createNewLOA(LOARequest);
res.sendStatus(201);
} catch (error) {
console.error(error);
res.status(500).send(error);
}
});
//admin posts LOA
router.post("/admin", [requireRole("17th Administrator")], async (req: Request, res: Response) => {
let LOARequest = req.body as LOARequest;
LOARequest.created_by = req.user.id;
LOARequest.filed_date = new Date();
console.log(LOARequest);
try {
await createNewLOA(LOARequest);
res.sendStatus(201);
} catch (error) {
console.error(error);
res.status(500).send(error);
}
});
//get my current LOA
router.get("/me", async (req: Request, res: Response) => {
const user = req.user.id;
try {
const result = await getUserLOA(user);
res.status(200).json(result)
} catch (error) {
console.error(error);
res.status(500).send(error);
}
})
//get my LOA history
router.get("/history", async (req: Request, res: Response) => {
try {
const user = req.user.id;
const page = Number(req.query.page) || undefined;
const pageSize = Number(req.query.pageSize) || undefined;
const result = await getUserLOA(user, page, pageSize);
res.status(200).json(result)
} catch (error) {
console.error(error);
res.status(500).send(error);
}
})
router.get('/all', [requireRole("17th Administrator")], async (req: Request, res: Response) => {
try {
const page = Number(req.query.page) || undefined;
const pageSize = Number(req.query.pageSize) || undefined;
const result = await getAllLOA(page, pageSize);
res.status(200).json(result)
} catch (error) {
console.error(error);
res.status(500).send(error);
}
})
router.get('/types', async (req: Request, res: Response) => {
try {
let out = await getLoaTypes();
res.status(200).json(out);
} catch (error) {
res.status(500).json(error);
console.error(error);
}
})
router.post('/cancel/:id', async (req: Request, res: Response) => {
let closer = req.user.id;
let id = Number(req.params.id);
try {
let loa = await getLOAbyID(id);
if (loa.member_id != closer) {
return res.sendStatus(403);
}
await closeLOA(Number(req.params.id), closer);
res.sendStatus(200);
} catch (error) {
console.error(error);
res.status(500).json(error);
}
})
//TODO: enforce admin only
router.post('/adminCancel/:id', [requireRole("17th Administrator")], async (req: Request, res: Response) => {
let closer = req.user.id;
try {
await closeLOA(Number(req.params.id), closer);
res.sendStatus(200);
} catch (error) {
console.error(error);
res.status(500).json(error);
}
})
// TODO: Enforce admin only
router.post('/extend/:id', [requireRole("17th Administrator")], async (req: Request, res: Response) => {
const to: Date = req.body.to;
if (!to) {
res.status(400).send("Extension length is required");
}
try {
await setLOAExtension(Number(req.params.id), to);
res.sendStatus(200);
} catch (error) {
console.error(error)
res.status(500).json(error);
}
})
router.get('/policy', async (req: Request, res: Response) => {
const output = await fetch(`${process.env.DOC_HOST}/api/pages/42`, {
headers: {
Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`,
}
})
if (output.ok) {
const out = await output.json();
res.status(200).json(out.html);
} else {
console.error("Failed to fetch LOA policy from bookstack");
res.sendStatus(500);
}
})
export const loaRouter = router;

View File

@@ -1,84 +0,0 @@
const express = require('express');
const router = express.Router();
import pool from '../db';
import { getUserData } from '../services/memberService';
import { getUserRoles } from '../services/rolesService';
router.use((req, res, next) => {
console.log(req.user);
console.log('Time:', Date.now())
next()
})
//get all users
router.get('/', async (req, res) => {
try {
const result = await pool.query(
`SELECT
v.*,
CASE
WHEN EXISTS (
SELECT 1
FROM leave_of_absences l
WHERE l.member_id = v.member_id
AND l.deleted = 0
AND UTC_TIMESTAMP() BETWEEN l.start_date AND l.end_date
) THEN 1 ELSE 0
END AS on_loa
FROM view_member_rank_status_all v;`);
return res.status(200).json(result);
} catch (err) {
console.error('Error fetching users:', err);
return res.status(500).json({ error: 'Failed to fetch users' });
}
});
router.get('/me', async (req, res) => {
if (req.user === undefined)
return res.sendStatus(401)
try {
const { id, name, state } = await getUserData(req.user.id);
const LOAData = await pool.query(
`SELECT *
FROM leave_of_absences
WHERE member_id = ?
AND deleted = 0
AND UTC_TIMESTAMP() BETWEEN start_date AND end_date;`, req.user.id);
const roleData = await getUserRoles(req.user.id);
const userDataFull = { id, name, state, LOAData, roleData };
console.log(userDataFull)
res.status(200).json(userDataFull);
} catch (error) {
console.error('Error fetching user data:', error);
return res.status(500).json({ error: 'Failed to fetch user data' });
}
})
router.get('/:id', async (req, res) => {
try {
const userId = req.params.id;
const result = await pool.query('SELECT * FROM view_member_rank_status_all WHERE id = $1;', [userId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
return res.status(200).json(result.rows[0]);
} catch (err) {
console.error('Error fetching user:', err);
return res.status(500).json({ error: 'Failed to fetch user' });
}
});
//update a user's display name (stub)
router.put('/:id/displayname', async (req, res) => {
// Stub: not implemented yet
return res.status(501).json({ error: 'Update display name not implemented' });
});
module.exports = router;

132
api/src/routes/members.ts Normal file
View File

@@ -0,0 +1,132 @@
const express = require('express');
const router = express.Router();
import { Request, Response } from 'express';
import pool from '../db';
import { requireLogin, requireMemberState, requireRole } from '../middleware/auth';
import { getUserActiveLOA } from '../services/loaService';
import { getAllMembersLite, getMemberSettings, getMembersFull, getMembersLite, getUserData, getUserState, setUserSettings } from '../services/memberService';
import { getUserRoles } from '../services/rolesService';
import { memberSettings, MemberState, myData } from '@app/shared/types/member';
//get all users
router.get('/', [requireLogin, requireMemberState(MemberState.Member)], async (req, res) => {
try {
const result = await pool.query(
`SELECT
v.*,
CASE
WHEN EXISTS (
SELECT 1
FROM leave_of_absences l
WHERE l.member_id = v.member_id
AND l.deleted = 0
AND UTC_TIMESTAMP() BETWEEN l.start_date AND l.end_date
) THEN 1 ELSE 0
END AS on_loa
FROM view_member_rank_unit_status_latest v;`);
return res.status(200).json(result);
} catch (err) {
console.error('Error fetching users:', err);
return res.status(500).json({ error: 'Failed to fetch users' });
}
});
router.get('/me', [requireLogin], async (req, res) => {
if (req.user === undefined)
return res.sendStatus(401)
try {
const memData = await getUserData(req.user.id);
const LOAData = await getUserActiveLOA(req.user.id);
const memState = await getUserState(req.user.id);
const roleData = await getUserRoles(req.user.id);
const userDataFull: myData = { member: memData, LOAs: LOAData, roles: roleData, state: memState };
res.status(200).json(userDataFull);
} catch (error) {
console.error('Error fetching user data:', error);
return res.status(500).json({ error: 'Failed to fetch user data' });
}
})
router.get('/settings', [requireLogin], async (req: Request, res: Response) => {
try {
let user = req.user.id;
console.log(user);
let output = await getMemberSettings(user);
res.status(200).json(output);
} catch (error) {
console.error(error);
res.status(500).json(error);
}
})
router.put('/settings', [requireLogin], async (req: Request, res: Response) => {
try {
let user = req.user.id;
let settings: memberSettings = req.body;
console.log(settings)
await setUserSettings(user, settings);
res.sendStatus(200);
} catch (error) {
console.error(error);
res.status(500).json(error);
}
})
router.get('/lite', [requireLogin], async (req: Request, res: Response) => {
try {
let out = await getAllMembersLite();
res.status(200).json(out);
} catch (error) {
console.error(error);
res.status(500).json(error);
}
})
router.post('/lite/bulk', async (req: Request, res: Response) => {
try {
let ids = req.body.ids;
let out = await getMembersLite(ids);
res.status(200).json(out);
} catch (error) {
console.error(error);
res.status(500).json(error);
}
})
router.post('/full/bulk', async (req: Request, res: Response) => {
try {
let ids = req.body.ids;
let out = await getMembersFull(ids);
res.status(200).json(out);
} catch (error) {
console.error(error);
res.status(500).json(error);
}
})
router.get('/:id', [requireLogin], async (req, res) => {
try {
const userId = req.params.id;
const result = await pool.query('SELECT * FROM view_member_rank_unit_status_latest WHERE id = $1;', [userId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
return res.status(200).json(result.rows[0]);
} catch (err) {
console.error('Error fetching user:', err);
return res.status(500).json({ error: 'Failed to fetch user' });
}
});
//update a user's display name (stub)
router.put('/:id/displayname', async (req, res) => {
// Stub: not implemented yet
return res.status(501);
});
export const memberRouter = router;

View File

@@ -1,10 +1,18 @@
const express = require('express'); import { MemberState } from "@app/shared/types/member";
import { requireLogin, requireMemberState, requireRole } from "../middleware/auth";
import { getAllRanks, insertMemberRank } from "../services/rankService";
import express = require('express');
const r = express.Router(); const r = express.Router();
const ur = express.Router(); const ur = express.Router();
const { getAllRanks, insertMemberRank } = require('../services/rankService')
r.use(requireLogin)
ur.use(requireLogin)
//insert a new latest rank for a user //insert a new latest rank for a user
ur.post('/', async (req, res) => {3 ur.post('/', [requireRole(["17th Command", "17th Administrator", "17th HQ"]), requireMemberState(MemberState.Member)], async (req, res) => {
3
try { try {
const change = req.body?.change; const change = req.body?.change;
await insertMemberRank(change.member_id, change.rank_id, change.date); await insertMemberRank(change.member_id, change.rank_id, change.date);
@@ -27,5 +35,5 @@ r.get('/', async (req, res) => {
} }
}); });
module.exports.ranks = r; export const ranks = r;
module.exports.memberRanks = ur; export const memberRanks = ur;

View File

@@ -2,11 +2,16 @@ const express = require('express');
const r = express.Router(); const r = express.Router();
const ur = express.Router(); const ur = express.Router();
import { MemberState } from '@app/shared/types/member';
import pool from '../db'; import pool from '../db';
import { requireLogin, requireMemberState, requireRole } from '../middleware/auth';
import { assignUserGroup, createGroup } from '../services/rolesService'; import { assignUserGroup, createGroup } from '../services/rolesService';
r.use(requireLogin)
ur.use(requireLogin)
//manually assign a member to a group //manually assign a member to a group
ur.post('/', async (req, res) => { ur.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => {
try { try {
const body = req.body; const body = req.body;
@@ -20,7 +25,7 @@ ur.post('/', async (req, res) => {
}); });
//manually remove member from group //manually remove member from group
ur.delete('/', async (req, res) => { ur.delete('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => {
try { try {
const body = req.body; const body = req.body;
console.log(body); console.log(body);
@@ -38,9 +43,9 @@ ur.delete('/', async (req, res) => {
}) })
//get all roles //get all roles
r.get('/', async (req, res) => { r.get('/', [requireMemberState(MemberState.Member)], async (req, res) => {
try { try {
const con = await pool.getConnection(); var con = await pool.getConnection();
// Get all roles // Get all roles
const roles = await con.query('SELECT * FROM roles;'); const roles = await con.query('SELECT * FROM roles;');
@@ -49,7 +54,7 @@ r.get('/', async (req, res) => {
const membersRoles = await con.query(` const membersRoles = await con.query(`
SELECT mr.role_id, v.* SELECT mr.role_id, v.*
FROM members_roles mr FROM members_roles mr
JOIN view_member_rank_status_all v ON mr.member_id = v.member_id JOIN view_member_rank_unit_status_latest v ON mr.member_id = v.member_id
`); `);
@@ -68,16 +73,17 @@ r.get('/', async (req, res) => {
members: roleIdToMembers[role.id] || [] members: roleIdToMembers[role.id] || []
})); }));
con.release();
res.json(result); res.json(result);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} finally {
con.release();
} }
}); });
//create a new role //create a new role
r.post('/', async (req, res) => { r.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => {
try { try {
const { name, color, description } = req.body; const { name, color, description } = req.body;
console.log('Creating role:', { name, color, description }); console.log('Creating role:', { name, color, description });
@@ -99,7 +105,7 @@ r.post('/', async (req, res) => {
} }
}) })
r.delete('/:id', async (req, res) => { r.delete('/:id', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => {
try { try {
const id = req.params.id; const id = req.params.id;
@@ -112,5 +118,5 @@ r.delete('/:id', async (req, res) => {
} }
}) })
module.exports.roles = r; export const roles = r;
module.exports.memberRoles = ur; export const memberRoles = ur;

View File

@@ -1,11 +1,15 @@
const express = require('express'); import express = require('express');
const status = express.Router(); const statusR = express.Router();
const memberStatus = express.Router(); const memberStatusR = express.Router();
import pool from '../db'; import pool from '../db';
import { requireLogin } from '../middleware/auth';
statusR.use(requireLogin);
memberStatusR.use(requireLogin);
//insert a new latest rank for a user //insert a new latest rank for a user
memberStatus.post('/', async (req, res) => { memberStatusR.post('/', async (req, res) => {
// try { // try {
// const App = req.body?.App || {}; // const App = req.body?.App || {};
@@ -30,7 +34,7 @@ memberStatus.post('/', async (req, res) => {
}); });
//get all statuses //get all statuses
status.get('/', async (req, res) => { statusR.get('/', async (req, res) => {
try { try {
const result = await pool.query('SELECT * FROM statuses;'); const result = await pool.query('SELECT * FROM statuses;');
res.json(result); res.json(result);
@@ -40,7 +44,8 @@ status.get('/', async (req, res) => {
} }
}); });
module.exports.status = status; export const status = statusR;
module.exports.memberStatus = memberStatus; export const memberStatus = memberStatusR;
// TODO, implement get all ranks route with SQL stirng SELECT id, name, short_name, category, sort_id FROM ranks; // TODO, implement get all ranks route with SQL stirng SELECT id, name, short_name, category, sort_id FROM ranks;

View File

@@ -1,5 +1,6 @@
import pool from "../db" import pool from "../db"
import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails, CourseEventSummary, RawAttendeeRow } from "@app/shared/types/course" import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails, CourseEventSummary, RawAttendeeRow } from "@app/shared/types/course"
import { PagedData } from "@app/shared/types/pagination";
import { toDateTime } from "@app/shared/utils/time"; import { toDateTime } from "@app/shared/utils/time";
export async function getAllCourses(): Promise<Course[]> { export async function getAllCourses(): Promise<Course[]> {
const sql = "SELECT * FROM courses WHERE deleted = false ORDER BY name ASC;" const sql = "SELECT * FROM courses WHERE deleted = false ORDER BY name ASC;"
@@ -79,9 +80,9 @@ export async function getCourseEventDetails(id: number): Promise<CourseEventDeta
} }
export async function insertCourseEvent(event: CourseEventDetails): Promise<number> { export async function insertCourseEvent(event: CourseEventDetails): Promise<number> {
console.log(event);
const con = await pool.getConnection();
try { try {
var con = await pool.getConnection();
await con.beginTransaction(); 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]); 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; var eventID: number = res.insertId;
@@ -98,16 +99,17 @@ export async function insertCourseEvent(event: CourseEventDetails): Promise<numb
VALUES (?, ?, ?, ?, ?, ?);`, [attendee.attendee_id, eventID, attendee.attendee_role_id, attendee.passed_bookwork, attendee.passed_qual, attendee.remarks]); VALUES (?, ?, ?, ?, ?, ?);`, [attendee.attendee_id, eventID, attendee.attendee_role_id, attendee.passed_bookwork, attendee.passed_qual, attendee.remarks]);
} }
await con.commit(); await con.commit();
await con.release();
return Number(eventID); return Number(eventID);
} catch (error) { } catch (error) {
await con.rollback(); if (con) await con.rollback();
await con.release();
throw error; throw error;
} finally {
if (con) await con.release();
} }
} }
export async function getCourseEvents(sortDir: string, search: string = ""): Promise<CourseEventSummary[]> { export async function getCourseEvents(sortDir: string, search: string = "", page = 1, pageSize = 10): Promise<PagedData<CourseEventSummary>> {
const offset = (page - 1) * pageSize;
let params = []; let params = [];
let searchString = ""; let searchString = "";
@@ -133,11 +135,23 @@ export async function getCourseEvents(sortDir: string, search: string = ""): Pro
LEFT JOIN members AS M LEFT JOIN members AS M
ON E.created_by = M.id ON E.created_by = M.id
${searchString} ${searchString}
ORDER BY E.event_date ${sortDir};`; ORDER BY E.event_date ${sortDir}
console.log(sql) LIMIT ? OFFSET ?;`;
console.log(params)
let events: CourseEventSummary[] = await pool.query(sql, params); let countSQL = `SELECT COUNT(*) AS count
return events; 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};`
let recordCount = Number((await pool.query(countSQL, [...params]))[0].count);
let pageCount = recordCount / pageSize;
let events: CourseEventSummary[] = await pool.query(sql, [...params, pageSize, offset]);
let output: PagedData<CourseEventSummary> = { data: events, pagination: { page: page, pageSize: pageSize, total: recordCount, totalPages: pageCount } }
return output;
} }
export async function getCourseEventRoles(): Promise<CourseAttendeeRole[]> { export async function getCourseEventRoles(): Promise<CourseAttendeeRole[]> {

View File

@@ -1,5 +1,6 @@
import { ApplicationListRow, ApplicationRow, CommentRow } from "@app/shared/types/application"; import { ApplicationListRow, ApplicationRow, CommentRow } from "@app/shared/types/application";
import pool from "../db"; import pool from "../db";
import { error } from "console";
export async function createApplication(memberID: number, appVersion: number, app: string) { export async function createApplication(memberID: number, appVersion: number, app: string) {
const sql = `INSERT INTO applications (member_id, app_version, app_data) VALUES (?, ?, ?);`; const sql = `INSERT INTO applications (member_id, app_version, app_data) VALUES (?, ?, ?);`;
@@ -12,12 +13,13 @@ export async function getMemberApplication(memberID: number): Promise<Applicatio
member.name AS member_name member.name AS member_name
FROM applications AS app FROM applications AS app
INNER JOIN members AS member ON member.id = app.member_id INNER JOIN members AS member ON member.id = app.member_id
WHERE app.member_id = ?;`; WHERE app.member_id = ? ORDER BY submitted_at DESC LIMIT 1;`;
let app: ApplicationRow[] = await pool.query(sql, [memberID]); let app: ApplicationRow[] = await pool.query(sql, [memberID]);
return app[0]; return app[0];
} }
export async function getApplicationByID(appID: number): Promise<ApplicationRow> { export async function getApplicationByID(appID: number): Promise<ApplicationRow> {
const sql = const sql =
`SELECT app.*, `SELECT app.*,
@@ -29,7 +31,9 @@ export async function getApplicationByID(appID: number): Promise<ApplicationRow>
return app[0]; return app[0];
} }
export async function getApplicationList(): Promise<ApplicationListRow[]> { export async function getApplicationList(page: number = 1, pageSize: number = 25): Promise<ApplicationListRow[]> {
const offset = (page - 1) * pageSize;
const sql = `SELECT const sql = `SELECT
member.name AS member_name, member.name AS member_name,
app.id, app.id,
@@ -38,34 +42,77 @@ export async function getApplicationList(): Promise<ApplicationListRow[]> {
app.app_status app.app_status
FROM applications AS app FROM applications AS app
LEFT JOIN members AS member LEFT JOIN members AS member
ON member.id = app.member_id;` ON member.id = app.member_id
ORDER BY app.submitted_at DESC
LIMIT ? OFFSET ?;`
const rows: ApplicationListRow[] = await pool.query(sql); const rows: ApplicationListRow[] = await pool.query(sql, [pageSize, offset]);
return rows; return rows;
} }
export async function approveApplication(id) { export async function getAllMemberApplications(memberID: number): Promise<ApplicationListRow[]> {
const sql = `SELECT
app.id,
app.member_id,
app.submitted_at,
app.app_status
FROM applications AS app WHERE app.member_id = ? ORDER BY submitted_at DESC;`;
const rows: ApplicationListRow[] = await pool.query(sql, [memberID])
return rows;
}
export async function approveApplication(id: number, approver: number) {
const sql = ` const sql = `
UPDATE applications UPDATE applications
SET approved_at = NOW() SET approved_at = NOW(), approved_by = ?
WHERE id = ? WHERE id = ?
AND approved_at IS NULL AND approved_at IS NULL
AND denied_at IS NULL AND denied_at IS NULL
`; `;
const result = await pool.execute(sql, id); const result = await pool.execute(sql, [approver, id]);
return result; console.log(result);
if (result.affectedRows == 1) {
return
} else {
throw new Error(`"Something went wrong approving application with ID ${id}`);
}
} }
export async function getApplicationComments(appID: number): Promise<CommentRow[]> { export async function denyApplication(id: number, approver: number) {
const sql = `
UPDATE applications
SET denied_at = NOW(), approved_by = ?
WHERE id = ?
AND approved_at IS NULL
AND denied_at IS NULL
`;
const result = await pool.execute(sql, [approver, id]);
console.log(result);
if (result.affectedRows == 1) {
return
} else {
throw new Error(`"Something went wrong denying application with ID ${id}`);
}
}
export async function getApplicationComments(appID: number, admin: boolean = false): Promise<CommentRow[]> {
const excludeAdmin = ' AND app.admin_only = false';
const whereClause = `WHERE app.application_id = ?${!admin ? excludeAdmin : ''}`;
return await pool.query(`SELECT app.id AS comment_id, return await pool.query(`SELECT app.id AS comment_id,
app.post_content, app.post_content,
app.poster_id, app.poster_id,
app.post_time, app.post_time,
app.last_modified, app.last_modified,
app.admin_only,
member.name AS poster_name member.name AS poster_name
FROM application_comments AS app FROM application_comments AS app
INNER JOIN members AS member ON member.id = app.poster_id INNER JOIN members AS member ON member.id = app.poster_id
WHERE app.application_id = ?;`, ${whereClause}`,
[appID]); [appID]);
} }

View File

@@ -0,0 +1,111 @@
import { toDateTime } from "@app/shared/utils/time";
import pool from "../db";
import { LOARequest, LOAType } from '@app/shared/types/loa'
import { PagedData } from '@app/shared/types/pagination'
export async function getLoaTypes(): Promise<LOAType[]> {
return await pool.query('SELECT * FROM leave_of_absences_types;');
}
export async function getAllLOA(page = 1, pageSize = 10): Promise<PagedData<LOARequest>> {
const offset = (page - 1) * pageSize;
const sql = `
SELECT loa.*, members.name, t.name AS type_name
FROM leave_of_absences AS loa
LEFT JOIN members ON loa.member_id = members.id
LEFT JOIN leave_of_absences_types AS t ON loa.type_id = t.id
ORDER BY
CASE
WHEN loa.closed IS NULL
AND NOW() > COALESCE(loa.extended_till, loa.end_date) THEN 1
WHEN loa.closed IS NULL
AND NOW() BETWEEN loa.start_date AND COALESCE(loa.extended_till, loa.end_date) THEN 2
WHEN loa.closed IS NULL AND NOW() < loa.start_date THEN 3
WHEN loa.closed IS NOT NULL THEN 4
END,
loa.start_date DESC
LIMIT ? OFFSET ?;
`;
let loaList: LOARequest[] = await pool.query(sql, [pageSize, offset]) as LOARequest[];
let loaCount = Number((await pool.query(`SELECT COUNT(*) as count FROM leave_of_absences;`))[0].count);
let pageCount = loaCount / pageSize;
let output: PagedData<LOARequest> = { data: loaList, pagination: { page: page, pageSize: pageSize, total: loaCount, totalPages: pageCount } }
return output;
}
export async function getUserLOA(userId: number, page = 1, pageSize = 10): Promise<PagedData<LOARequest>> {
const offset = (page - 1) * pageSize;
const result: LOARequest[] = await pool.query(`
SELECT loa.*, members.name, t.name AS type_name
FROM leave_of_absences AS loa
LEFT JOIN members ON loa.member_id = members.id
LEFT JOIN leave_of_absences_types AS t ON loa.type_id = t.id
WHERE member_id = ?
ORDER BY
CASE
WHEN loa.closed IS NULL
AND NOW() > COALESCE(loa.extended_till, loa.end_date) THEN 1
WHEN loa.closed IS NULL
AND NOW() BETWEEN loa.start_date AND COALESCE(loa.extended_till, loa.end_date) THEN 2
WHEN loa.closed IS NULL AND NOW() < loa.start_date THEN 3
WHEN loa.closed IS NOT NULL THEN 4
END,
loa.start_date DESC
LIMIT ? OFFSET ?;`, [userId, pageSize, offset])
let loaCount = Number((await pool.query(`SELECT COUNT(*) as count FROM leave_of_absences WHERE member_id = ?;`, [userId]))[0].count);
let pageCount = loaCount / pageSize;
let output: PagedData<LOARequest> = { data: result, pagination: { page: page, pageSize: pageSize, total: loaCount, totalPages: pageCount } }
return output;
}
export async function getUserActiveLOA(userId: number): Promise<LOARequest[]> {
const sql = `SELECT *
FROM leave_of_absences
WHERE member_id = ?
AND closed IS NULL
AND UTC_TIMESTAMP() BETWEEN start_date AND end_date;`
const LOAData = await pool.query(sql, [userId]);
return LOAData;
}
export async function createNewLOA(data: LOARequest) {
const sql = `INSERT INTO leave_of_absences
(member_id, filed_date, start_date, end_date, type_id, reason)
VALUES (?, ?, ?, ?, ?, ?)`;
await pool.query(sql, [data.member_id, toDateTime(data.filed_date), toDateTime(data.start_date), toDateTime(data.end_date), data.type_id, data.reason])
return;
}
export async function closeLOA(id: number, closer: number) {
const sql = `UPDATE leave_of_absences
SET closed = 1,
closed_by = ?,
ended_at = NOW()
WHERE leave_of_absences.id = ?`;
let out = await pool.query(sql, [closer, id]);
console.log(out);
return out;
}
export async function getLOAbyID(id: number): Promise<LOARequest> {
let res = await pool.query(`SELECT * FROM leave_of_absences WHERE id = ?`, [id]);
console.log(res);
if (res.length != 1)
throw new Error(`LOA with id ${id} not found`);
return res[0];
}
export async function setLOAExtension(id: number, extendTo: Date) {
let res = await pool.query(`UPDATE leave_of_absences
SET extended_till = ?
WHERE leave_of_absences.id = ? `, [toDateTime(extendTo), id]);
if (res.affectedRows != 1)
throw new Error(`Could not extend LOA`);
return res[0];
}

View File

@@ -1,34 +1,74 @@
import pool from "../db"; import pool from "../db";
import { Member, MemberLight, memberSettings, MemberState } from '@app/shared/types/member'
export enum MemberState { export async function getUserData(userID: number): Promise<Member> {
Guest = "guest", const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id = ?`;
Applicant = "applicant", const res: Member = await pool.query(sql, [userID]);
Member = "member", return res[0] ?? null;
Retired = "retired",
Banned = "banned",
Denied = "denied"
}
export async function getUserData(userID: number) {
const sql = `SELECT * FROM members WHERE id = ?`;
const res = await pool.query(sql, [userID]);
return res[0] ?? null;
} }
export async function setUserState(userID: number, state: MemberState) { export async function setUserState(userID: number, state: MemberState) {
const sql = `UPDATE members const sql = `UPDATE members
SET state = ? SET state = ?
WHERE id = ?;`; WHERE id = ?;`;
return await pool.query(sql, [state, userID]); return await pool.query(sql, [state, userID]);
} }
declare global { export async function getUserState(user: number): Promise<MemberState> {
namespace Express { let out = await pool.query(`SELECT state FROM members WHERE id = ?`, [user]);
interface Request { return (out[0].state as MemberState);
user: {
id: number;
name: string;
};
}
}
} }
export async function getMemberSettings(id: number): Promise<memberSettings> {
const sql = `SELECT * FROM view_member_settings WHERE id = ?`;
let out: memberSettings[] = await pool.query(sql, [id]);
if (out.length != 1)
throw new Error("Could not get user settings");
return out[0];
}
export async function setUserSettings(id: number, settings: memberSettings) {
const sql = `UPDATE view_member_settings SET
displayName = ?
WHERE id = ?;`;
let result = await pool.query(sql, [settings.displayName, id])
console.log(result);
}
export async function getMembersLite(ids: number[]): Promise<MemberLight[]> {
const sql = `SELECT m.member_id AS id,
m.member_name AS username,
m.displayName,
u.color
FROM view_member_rank_unit_status_latest m
LEFT JOIN units u ON u.name = m.unit
WHERE member_id IN (?);`;
const res: MemberLight[] = await pool.query(sql, [ids]);
return res;
}
export async function getAllMembersLite(): Promise<MemberLight[]> {
const sql = `SELECT m.member_id AS id,
m.member_name AS username,
m.displayName,
u.color
FROM view_member_rank_unit_status_latest m
LEFT JOIN units u ON u.name = m.unit;`;
const res: MemberLight[] = await pool.query(sql);
return res;
}
export async function getMembersFull(ids: number[]): Promise<Member[]> {
const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id IN (?);`;
const res: Member[] = await pool.query(sql, [ids]);
return res;
}
export async function mapDiscordtoID(id: number): Promise<number | null> {
const sql = `SELECT id FROM members WHERE discord_id = ?;`
let res = await pool.query(sql, [id]);
return res.length > 0 ? res[0].id : null;
}

View File

@@ -21,8 +21,8 @@ export async function insertMemberRank(member_id: number, rank_id: number): Prom
export async function insertMemberRank(member_id: number, rank_id: number, date?: Date): Promise<void> { export async function insertMemberRank(member_id: number, rank_id: number, date?: Date): Promise<void> {
const sql = date const sql = date
? `INSERT INTO members_ranks (member_id, rank_id, event_date) VALUES (?, ?, ?);` ? `INSERT INTO members_ranks (member_id, rank_id, start_date) VALUES (?, ?, ?);`
: `INSERT INTO members_ranks (member_id, rank_id, event_date) VALUES (?, ?, NOW());`; : `INSERT INTO members_ranks (member_id, rank_id, start_date) VALUES (?, ?, NOW());`;
const params = date const params = date
? [member_id, rank_id, date] ? [member_id, rank_id, date]

View File

@@ -1,6 +1,6 @@
import pool from "../db" import pool from "../db"
export async function assignUserToStatus(userID: number, statusID: number) { export async function assignUserToStatus(userID: number, statusID: number) {
const sql = `INSERT INTO members_statuses (member_id, status_id, event_date) VALUES (?, ?, NOW())` const sql = `INSERT INTO members_statuses (member_id, status_id, start_date) VALUES (?, ?, NOW())`
await pool.execute(sql, [userID, statusID]); await pool.execute(sql, [userID, statusID]);
} }

View File

@@ -0,0 +1,51 @@
import * as z from "zod";
import { LOAType } from "../types/loa";
export const loaTypeSchema = z.object({
id: z.number(),
name: z.string(),
max_length_days: z.number(),
});
export const loaSchema = z.object({
member_id: z.number(),
start_date: z.date(),
end_date: z.date(),
type: loaTypeSchema,
reason: z.string(),
})
.superRefine((data, ctx) => {
const { start_date, end_date, type } = data;
const today = new Date();
today.setHours(0, 0, 0, 0);
if (start_date < today) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["start_date"],
message: "Start date cannot be in the past.",
});
}
// 1. end > start
if (end_date <= start_date) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["end_date"],
message: "End date must be after start date.",
});
}
// 2. calculate max
const maxEnd = new Date(start_date);
maxEnd.setDate(maxEnd.getDate() + type.max_length_days);
if (end_date > maxEnd) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["end_date"],
message: `This LOA type allows a maximum of ${type.max_length_days} days.`,
});
}
});

View File

@@ -40,6 +40,7 @@ export interface CommentRow {
post_time: string; post_time: string;
last_modified: string | null; last_modified: string | null;
poster_name: string; poster_name: string;
admin_only: boolean;
} }
export interface ApplicationFull { export interface ApplicationFull {

View File

@@ -26,7 +26,7 @@ export interface CalendarSignup {
eventID: number; eventID: number;
status: CalendarAttendance; status: CalendarAttendance;
member_name?: string; member_name?: string;
member_unit?: string; unit_name?: string;
} }
export interface CalendarEventShort { export interface CalendarEventShort {

24
shared/types/loa.ts Normal file
View File

@@ -0,0 +1,24 @@
export interface LOARequest {
id?: number;
member_id?: number;
filed_date?: Date; // ISO 8601 string
start_date: Date; // ISO 8601 string
end_date: Date; // ISO 8601 string
extended_till?: Date;
type_id?: number;
reason?: string;
expired?: boolean;
closed?: boolean;
closed_by?: number;
created_by?: number;
name?: string; //member name
type_name?: string;
};
export interface LOAType {
id: number;
name: string;
max_length_days: number;
extendable: boolean;
}

42
shared/types/member.ts Normal file
View File

@@ -0,0 +1,42 @@
import { LOARequest } from "./loa";
import { Role } from "./roles";
export interface memberSettings {
displayName: string;
}
export enum MemberState {
Guest = "guest",
Applicant = "applicant",
Member = "member",
Retired = "retired",
Banned = "banned",
Denied = "denied"
}
export type Member = {
member_id: number;
member_name: string;
displayName?: string;
rank: string | null;
rank_date: string | null;
unit: string | null;
unit_date: string | null;
status: string | null;
status_date: string | null;
loa_until?: Date;
};
export interface MemberLight {
id: number
displayName: string
username: string
color: string
}
export interface myData {
member: Member;
LOAs: LOARequest[];
roles: Role[];
state: MemberState;
}

View File

@@ -0,0 +1,11 @@
export interface PagedData<T> {
data: T[]
pagination: pagination
}
export interface pagination {
page: number
pageSize: number
total: number
totalPages: number
}

View File

@@ -1,5 +1,8 @@
export function toDateTime(date: Date): string { export function toDateTime(date: Date): string {
console.log(date); console.log(date);
if (typeof date === 'string') {
date = new Date(date);
}
// This produces a CST-local time because server runs in CST // This produces a CST-local time because server runs in CST
const year = date.getFullYear(); const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0"); const month = (date.getMonth() + 1).toString().padStart(2, "0");
@@ -9,4 +12,4 @@ export function toDateTime(date: Date): string {
const second = date.getSeconds().toString().padStart(2, "0"); const second = date.getSeconds().toString().padStart(2, "0");
return `${year}-${month}-${day} ${hour}:${minute}:${second}`; return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
} }

View File

@@ -1,5 +1,6 @@
# SITE SETTINGS # SITE SETTINGS
VITE_APIHOST= VITE_APIHOST=
VITE_DOCHOST= # https://bookstack.whatever.com/api
VITE_ENVIRONMENT= # dev / prod VITE_ENVIRONMENT= # dev / prod
VITE_APPLICATION_VERSION= # Should match release tag VITE_APPLICATION_VERSION= # Should match release tag

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>17th Ranger Battalion</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -5,6 +5,7 @@ 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'; import Navbar from './components/Navigation/Navbar.vue';
import { cancelLOA } from './api/loa';
const userStore = useUserStore(); const userStore = useUserStore();
@@ -29,10 +30,11 @@ const environment = import.meta.env.VITE_ENVIRONMENT;
<p>This is a development build of the application. Some features will be unavailable or unstable.</p> <p>This is a development build of the application. Some features will be unavailable or unstable.</p>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Alert v-if="userStore.user?.loa?.[0]" class="m-2 mx-auto w-5xl" variant="info"> <Alert v-if="userStore.user?.LOAs?.[0]" class="m-2 mx-auto w-5xl" variant="info">
<AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto"> <AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto">
<p>You are on LOA until <strong>{{ formatDate(userStore.user?.loa?.[0].end_date) }}</strong></p> <p>You are on LOA until <strong>{{ formatDate(userStore.user?.LOAs?.[0].extended_till || userStore.user?.LOAs?.[0].end_date) }}</strong></p>
<Button variant="secondary">End LOA</Button> <Button variant="secondary" @click="async () => { await cancelLOA(userStore.user.LOAs?.[0].id); userStore.loadUser(); }">End
LOA</Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div> </div>

View File

@@ -1,80 +1,11 @@
export type ApplicationDto = Partial<{ import { ApplicationFull } from "@shared/types/application";
age: number | string
name: string
playtime: number | string
hobbies: string
military: boolean
communities: string
joinReason: string
milsimAttraction: string
referral: string
steamProfile: string
timezone: string
canAttendSaturday: boolean
interests: string
aknowledgeRules: boolean
}>
export interface ApplicationData {
dob: string;
name: string;
playtime: number;
hobbies: string;
military: boolean;
communities: string;
joinReason: string;
milsimAttraction: string;
referral: string;
steamProfile: string;
timezone: string;
canAttendSaturday: boolean;
interests: string;
aknowledgeRules: boolean;
}
//reflects how applications are stored in the database
export interface ApplicationRow {
id: number;
member_id: number;
app_version: number;
app_data: ApplicationData;
submitted_at: string; // ISO datetime from DB (e.g., "2025-08-25T18:04:29.000Z")
updated_at: string | null;
approved_at: string | null;
denied_at: string | null;
app_status: ApplicationStatus; // generated column
decision_at: string | null; // generated column
// present when you join members (e.g., SELECT a.*, m.name AS member_name)
member_name: string;
}
export interface CommentRow {
comment_id: number;
post_content: string;
poster_id: number;
post_time: string;
last_modified: string | null;
poster_name: string;
}
export interface ApplicationFull {
application: ApplicationRow;
comments: CommentRow[];
}
export enum ApplicationStatus {
Pending = "Pending",
Accepted = "Accepted",
Denied = "Denied",
}
// @ts-ignore // @ts-ignore
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, asAdmin: boolean = false): Promise<ApplicationFull | null> {
const res = await fetch(`${addr}/application/${id}`, { credentials: 'include' }) const res = await fetch(`${addr}/application/${id}?admin=${asAdmin}`, { 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()
@@ -104,6 +35,22 @@ export async function postChatMessage(message: any, post_id: number) {
const response = await fetch(`${addr}/application/${post_id}/comment`, { const response = await fetch(`${addr}/application/${post_id}/comment`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(out),
})
return await response.json();
}
export async function postAdminChatMessage(message: any, post_id: number) {
const out = {
message: message
}
const response = await fetch(`${addr}/application/${post_id}/adminComment`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(out), body: JSON.stringify(out),
}) })
@@ -112,7 +59,9 @@ export async function postChatMessage(message: any, post_id: number) {
} }
export async function getAllApplications(): Promise<ApplicationFull> { export async function getAllApplications(): Promise<ApplicationFull> {
const res = await fetch(`${addr}/application/all`) const res = await fetch(`${addr}/application/all`, {
credentials: 'include',
})
if (res.ok) { if (res.ok) {
return res.json() return res.json()
@@ -121,18 +70,67 @@ export async function getAllApplications(): Promise<ApplicationFull> {
} }
} }
export async function approveApplication(id: Number) { export async function loadMyApplications(): Promise<ApplicationFull> {
const res = await fetch(`${addr}/application/approve/${id}`, { method: 'POST' }) const res = await fetch(`${addr}/application/meList`, { credentials: 'include' })
if (!res.ok) { if (res.ok) {
return res.json()
} else {
console.error("Something went wrong approving the application") console.error("Something went wrong approving the application")
} }
} }
export async function denyApplication(id: Number) { export async function getMyApplication(id: number): Promise<ApplicationFull> {
const res = await fetch(`${addr}/application/deny/${id}`, { method: 'POST' }) const res = await fetch(`${addr}/application/me/${id}`, { credentials: 'include' })
if (res.status === 204) return null
if (res.status === 403) throw new Error("Unauthorized");
if (!res.ok) throw new Error('Failed to load application')
const json = await res.json()
// Accept either the object at root or under `application`
return json;
}
export async function approveApplication(id: Number) {
const res = await fetch(`${addr}/application/approve/${id}`, { method: 'POST', credentials: 'include' })
if (!res.ok) { if (!res.ok) {
console.error("Something went wrong denying the application") throw new Error("Something went wrong approving the application");
}
return;
}
export async function denyApplication(id: Number) {
const res = await fetch(`${addr}/application/deny/${id}`, { method: 'POST', credentials: 'include' })
if (!res.ok) {
throw new Error("Something went wrong denyting the application");
}
return;
}
export async function restartApplication() {
const res = await fetch(`${addr}/application/restart`, {
method: 'POST',
credentials: 'include'
})
if (!res.ok) {
console.error("Something went wrong restarting your application")
}
}
export async function getCoC(): Promise<string> {
const res = await fetch(`${addr}/application/coc`, {
method: "GET",
credentials: 'include',
});
if (res.ok) {
const out = res.json();
if (!out) {
return null;
}
return out;
} else {
return null;
} }
} }

View File

@@ -100,7 +100,6 @@ export async function editCalendarEvent(eventData: CalendarEvent) {
export async function setCancelCalendarEvent(eventID: number, cancel: boolean) { export async function setCancelCalendarEvent(eventID: number, cancel: boolean) {
let route = cancel ? "cancel" : "uncancel"; let route = cancel ? "cancel" : "uncancel";
console.log(route);
let res = await fetch(`${addr}/calendar/${eventID}/${route}`, { let res = await fetch(`${addr}/calendar/${eventID}/${route}`, {
method: "POST", method: "POST",
credentials: "include" credentials: "include"

View File

@@ -1,12 +1,5 @@
export type LOARequest = { import { LOARequest, LOAType } from '@shared/types/loa'
id?: number; import { PagedData } from '@shared/types/pagination'
name?: string;
member_id: number;
filed_date: string; // ISO 8601 string
start_date: string; // ISO 8601 string
end_date: string; // ISO 8601 string
reason?: string;
};
// @ts-ignore // @ts-ignore
const addr = import.meta.env.VITE_APIHOST; const addr = import.meta.env.VITE_APIHOST;
@@ -17,21 +10,41 @@ export async function submitLOA(request: LOARequest): Promise<{ id?: number; err
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(request), body: JSON.stringify(request),
credentials: 'include',
}); });
if (res.ok) { if (res.ok) {
return res.json(); return;
} else { } else {
return { error: "Failed to submit LOA" }; throw new Error("Failed to submit LOA");
} }
} }
export async function adminSubmitLOA(request: LOARequest): Promise<{ id?: number; error?: string }> {
const res = await fetch(`${addr}/loa/admin`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
credentials: 'include',
});
if (res.ok) {
return
} else {
throw new Error("Failed to submit LOA");
}
}
export async function getMyLOA(): Promise<LOARequest | null> { export async function getMyLOA(): Promise<LOARequest | null> {
const res = await fetch(`${addr}/loa/me`, { const res = await fetch(`${addr}/loa/me`, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: 'include',
}); });
@@ -46,12 +59,49 @@ export async function getMyLOA(): Promise<LOARequest | null> {
} }
} }
export function getAllLOAs(): Promise<LOARequest[]> { export async function getAllLOAs(page?: number, pageSize?: number): Promise<PagedData<LOARequest>> {
return fetch(`${addr}/loa/all`, { const params = new URLSearchParams();
if (page !== undefined) {
params.set("page", page.toString());
}
if (pageSize !== undefined) {
params.set("pageSize", pageSize.toString());
}
return fetch(`${addr}/loa/all?${params}`, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: 'include',
}).then((res) => {
if (res.ok) {
return res.json();
} else {
return [];
}
});
}
export function getMyLOAs(page?: number, pageSize?: number): Promise<PagedData<LOARequest>> {
const params = new URLSearchParams();
if (page !== undefined) {
params.set("page", page.toString());
}
if (pageSize !== undefined) {
params.set("pageSize", pageSize.toString());
}
return fetch(`${addr}/loa/history?${params}`, {
method: "GET",
credentials: 'include',
headers: {
"Content-Type": "application/json",
},
}).then((res) => { }).then((res) => {
if (res.ok) { if (res.ok) {
return res.json(); return res.json();
@@ -59,4 +109,69 @@ export function getAllLOAs(): Promise<LOARequest[]> {
return []; return [];
} }
}); });
} }
export async function getLoaTypes(): Promise<LOAType[]> {
const res = await fetch(`${addr}/loa/types`, {
method: "GET",
credentials: 'include',
});
if (res.ok) {
const out = res.json();
if (!out) {
return null;
}
return out;
} else {
return null;
}
};
export async function getLoaPolicy(): Promise<string> {
const res = await fetch(`${addr}/loa/policy`, {
method: "GET",
credentials: 'include',
});
if (res.ok) {
const out = res.json();
if (!out) {
return null;
}
return out;
} else {
return null;
}
}
export async function cancelLOA(id: number, admin: boolean = false) {
let route = admin ? 'adminCancel' : 'cancel';
const res = await fetch(`${addr}/loa/${route}/${id}`, {
method: "POST",
credentials: 'include',
});
if (res.ok) {
return
} else {
throw new Error("Could not cancel LOA");
}
}
export async function extendLOA(id: number, to: Date) {
const res = await fetch(`${addr}/loa/extend/${id}`, {
method: "POST",
credentials: 'include',
body: JSON.stringify({ to }),
headers: {
"Content-Type": "application/json",
}
});
if (res.ok) {
return
} else {
throw new Error("Could not extend LOA");
}
}

View File

@@ -1,12 +1,4 @@
export type Member = { import { memberSettings, Member, MemberLight } from "@shared/types/member";
member_id: number;
member_name: string;
rank: string | null;
rank_date: string | null;
status: string | null;
status_date: string | null;
on_loa: boolean | null;
};
// @ts-ignore // @ts-ignore
const addr = import.meta.env.VITE_APIHOST; const addr = import.meta.env.VITE_APIHOST;
@@ -19,4 +11,80 @@ export async function getMembers(): Promise<Member[]> {
throw new Error("Failed to fetch members"); throw new Error("Failed to fetch members");
} }
return response.json(); return response.json();
}
export async function getMemberSettings(): Promise<memberSettings> {
const response = await fetch(`${addr}/members/settings`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error("Failed to fetch settings");
}
return response.json();
}
export async function setMemberSettings(settings: memberSettings) {
const response = await fetch(`${addr}/members/settings`, {
credentials: 'include',
method: 'PUT',
headers: {
'Content-Type': 'Application/json',
},
body: JSON.stringify(settings)
});
if (!response.ok) {
throw new Error("Failed to fetch settings");
}
return;
}
export async function getAllLightMembers(): Promise<MemberLight[]> {
const response = await fetch(`${addr}/members/lite`, {
credentials: 'include',
headers: {
"Content-Type": "application/json",
}
});
if (!response.ok) {
throw new Error("Failed to fetch light members");
}
return response.json();
}
export async function getLightMembers(ids: number[]): Promise<MemberLight[]> {
if (ids.length === 0) return [];
const response = await fetch(`${addr}/members/lite/bulk`, {
credentials: 'include',
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ids })
});
if (!response.ok) {
throw new Error("Failed to fetch light members");
}
return response.json();
}
export async function getFullMembers(ids: number[]): Promise<Member[]> {
if (ids.length === 0) return [];
const response = await fetch(`${addr}/members/full/bulk`, {
credentials: 'include',
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ids })
});
if (!response.ok) {
throw new Error("Failed to fetch settings");
}
return response.json();
} }

View File

@@ -1,13 +1,34 @@
import { Course, CourseAttendeeRole, CourseEventDetails, CourseEventSummary } from '@shared/types/course' import { Course, CourseAttendeeRole, CourseEventDetails, CourseEventSummary } from '@shared/types/course'
import { PagedData } from '@shared/types/pagination';
//@ts-ignore //@ts-ignore
const addr = import.meta.env.VITE_APIHOST; const addr = import.meta.env.VITE_APIHOST;
export async function getTrainingReports(sortMode: string, search: string): Promise<CourseEventSummary[]> { export async function getTrainingReports(sortMode?: string, search?: string, page?: number, pageSize?: number): Promise<PagedData<CourseEventSummary>> {
const res = await fetch(`${addr}/courseEvent?sort=${sortMode}&search=${search}`); const params = new URLSearchParams();
if (page !== undefined) {
params.set("page", page.toString());
}
if (pageSize !== undefined) {
params.set("pageSize", pageSize.toString());
}
if (sortMode !== undefined) {
params.set("sort", sortMode.toString());
}
if (search !== undefined || search !== "") {
params.set("search", search.toString());
}
const res = await fetch(`${addr}/courseEvent?${params}`, {
credentials: 'include',
});
if (res.ok) { if (res.ok) {
return await res.json() as Promise<CourseEventSummary[]>; return await res.json() as Promise<PagedData<CourseEventSummary>>;
} else { } else {
console.error("Something went wrong"); console.error("Something went wrong");
throw new Error("Failed to load training reports"); throw new Error("Failed to load training reports");
@@ -15,7 +36,9 @@ export async function getTrainingReports(sortMode: string, search: string): Prom
} }
export async function getTrainingReport(id: number): Promise<CourseEventDetails> { export async function getTrainingReport(id: number): Promise<CourseEventDetails> {
const res = await fetch(`${addr}/courseEvent/${id}`); const res = await fetch(`${addr}/courseEvent/${id}`, {
credentials: 'include',
});
if (res.ok) { if (res.ok) {
return await res.json() as Promise<CourseEventDetails>; return await res.json() as Promise<CourseEventDetails>;
@@ -26,10 +49,12 @@ export async function getTrainingReport(id: number): Promise<CourseEventDetails>
} }
export async function getAllTrainings(): Promise<Course[]> { export async function getAllTrainings(): Promise<Course[]> {
const res = await fetch(`${addr}/course`); const res = await fetch(`${addr}/course`, {
credentials: 'include',
});
if (res.ok) { if (res.ok) {
return await res.json() as Promise<Course[]>; return await res.json() as Promise<Course[]>;
} else { } else {
console.error("Something went wrong"); console.error("Something went wrong");
throw new Error("Failed to load training list"); throw new Error("Failed to load training list");
@@ -37,7 +62,9 @@ export async function getAllTrainings(): Promise<Course[]> {
} }
export async function getAllAttendeeRoles(): Promise<CourseAttendeeRole[]> { export async function getAllAttendeeRoles(): Promise<CourseAttendeeRole[]> {
const res = await fetch(`${addr}/course/roles`); const res = await fetch(`${addr}/course/roles`, {
credentials: 'include',
});
if (res.ok) { if (res.ok) {
return await res.json() as Promise<CourseAttendeeRole[]>; return await res.json() as Promise<CourseAttendeeRole[]>;

View File

@@ -165,4 +165,112 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
}
/* Root container */
.bookstack-container {
font-family: var(--font-sans, system-ui), sans-serif;
color: var(--foreground);
line-height: 1.45;
max-width: 760px;
margin: 0 auto;
font-size: 0.9rem;
}
/* Headers */
.bookstack-container h4 {
margin: 0.9rem 0 0.4rem 0;
font-weight: 600;
line-height: 1.35;
font-size: 1.05rem;
color: var(--foreground);
}
.bookstack-container h5 {
margin: 0.9rem 0 0.4rem 0;
font-weight: 600;
line-height: 1.35;
font-size: 0.95rem;
color: var(--foreground);
}
/* Lists */
.bookstack-container ul {
list-style-type: disc;
margin-left: 1.1rem;
margin-bottom: 0.6rem;
padding-left: 0.6rem;
color: var(--muted-foreground);
}
/* Nested lists */
.bookstack-container ul ul {
list-style-type: circle;
margin-left: 0.9rem;
}
/* List items */
.bookstack-container li {
margin: 0.15rem 0;
padding-left: 0.1rem;
color: var(--muted-foreground);
}
/* Bullet color */
.bookstack-container li::marker {
color: var(--muted-foreground);
}
/* Inline elements */
.bookstack-container li p,
.bookstack-container li span,
.bookstack-container p {
display: inline;
margin: 0;
padding: 0;
color: var(--muted-foreground);
}
/* Top-level spacing */
.bookstack-container>ul>li {
margin-top: 0.3rem;
}
/* links */
.bookstack-container a {
color: var(--color-primary);
margin-top: 0.3rem;
}
.bookstack-container a:hover {
text-decoration: underline;
}
/* Scrollbar stuff */
/* 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;
} }

View File

@@ -18,13 +18,17 @@ import NavigationMenuTrigger from '../ui/navigation-menu/NavigationMenuTrigger.v
import NavigationMenuContent from '../ui/navigation-menu/NavigationMenuContent.vue'; import NavigationMenuContent from '../ui/navigation-menu/NavigationMenuContent.vue';
import { navigationMenuTriggerStyle } from '../ui/navigation-menu/' import { navigationMenuTriggerStyle } from '../ui/navigation-menu/'
import { useAuth } from '@/composables/useAuth'; import { useAuth } from '@/composables/useAuth';
import { CircleArrowOutUpRight } from 'lucide-vue-next'; import { ArrowUpRight, CircleArrowOutUpRight } from 'lucide-vue-next';
import DropdownMenuGroup from '../ui/dropdown-menu/DropdownMenuGroup.vue';
import DropdownMenuSeparator from '../ui/dropdown-menu/DropdownMenuSeparator.vue';
const userStore = useUserStore(); const userStore = useUserStore();
const auth = useAuth(); const auth = useAuth();
//@ts-ignore //@ts-ignore
const APIHOST = import.meta.env.VITE_APIHOST; const APIHOST = import.meta.env.VITE_APIHOST;
//@ts-ignore
const DOCHOST = import.meta.env.VITE_DOCHOST;
async function logout() { async function logout() {
userStore.user = null; userStore.user = null;
@@ -58,10 +62,15 @@ function blurAfter() {
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
<!-- Members --> <!-- Docs -->
<NavigationMenuItem> <NavigationMenuItem>
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()"> <NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/" @click="blurAfter">Documents</RouterLink> <a href="https://docs.iceberg-gaming.com" target="_blank">
<span class="flex items-center gap-1">
Documents
<ArrowUpRight class="h-4 w-4" />
</span>
</a>
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
@@ -170,12 +179,17 @@ function blurAfter() {
<div> <div>
<DropdownMenu v-if="userStore.isLoggedIn"> <DropdownMenu v-if="userStore.isLoggedIn">
<DropdownMenuTrigger class="cursor-pointer"> <DropdownMenuTrigger class="cursor-pointer">
<p>{{ userStore.user.name }}</p> <Button variant="ghost" class="px-4">
{{ userStore.displayName }}
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<!-- <DropdownMenuItem>My Profile</DropdownMenuItem> --> <DropdownMenuItem @click="$router.push('/profile')">My Profile</DropdownMenuItem>
<DropdownMenuSeparator></DropdownMenuSeparator>
<!-- <DropdownMenuItem>Settings</DropdownMenuItem> --> <!-- <DropdownMenuItem>Settings</DropdownMenuItem> -->
<DropdownMenuItem @click="$router.push('/join')">My Application</DropdownMenuItem> <DropdownMenuItem @click="$router.push('/join')">My Application</DropdownMenuItem>
<DropdownMenuItem @click="$router.push('/applications')">Application History</DropdownMenuItem>
<DropdownMenuSeparator></DropdownMenuSeparator>
<DropdownMenuItem :variant="'destructive'" @click="logout()">Logout</DropdownMenuItem> <DropdownMenuItem :variant="'destructive'" @click="logout()">Logout</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -11,13 +11,22 @@ import {
import Textarea from '@/components/ui/textarea/Textarea.vue' import Textarea from '@/components/ui/textarea/Textarea.vue'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod' import * as z from 'zod'
import { useAuth } from '@/composables/useAuth'
import { CommentRow } from '@shared/types/application'
import { Dot } from 'lucide-vue-next'
import { ref } from 'vue'
import MemberCard from '../members/MemberCard.vue'
const props = defineProps<{ const props = withDefaults(defineProps<{
messages: Array<Record<string, any>> messages: CommentRow[]
}>() adminMode?: boolean
}>(), {
adminMode: false,
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'post', text: string): void (e: 'post', text: string): void
(e: 'postInternal', text: string): void
}>() }>()
const commentSchema = toTypedSchema( const commentSchema = toTypedSchema(
@@ -26,9 +35,14 @@ const commentSchema = toTypedSchema(
}) })
) )
const submitMode = ref("public");
// vee-validate passes (values, actions) to @submit // vee-validate passes (values, actions) to @submit
function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => void }) { function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => void }) {
emit('post', values.text.trim()) if (submitMode.value === "internal")
emit('postInternal', values.text.trim())
else
emit('post', values.text.trim())
resetForm() resetForm()
} }
</script> </script>
@@ -48,25 +62,31 @@ function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => vo
</FormField> </FormField>
<!-- Button below, right-aligned --> <!-- Button below, right-aligned -->
<div class="mt-2 flex justify-end"> <div class="mt-2 flex justify-end gap-2">
<Button type="submit">Post</Button> <Button v-if="adminMode" type="submit" @click="submitMode = 'internal'" variant="outline">Post (Internal)</Button>
<Button type="submit" @click="submitMode = 'public'">{{ adminMode ? 'Post (Public)' : 'Post' }}</Button>
</div> </div>
</Form> </Form>
<!-- Existing posts --> <!-- Existing posts -->
<div class="space-y-3"> <div class="space-y-3">
<div v-for="(message, i) in props.messages" :key="message.id ?? i" <div v-for="(message, i) in props.messages" :key="message.comment_id ?? i" class="rounded-md border p-3 space-y-5"
class="rounded-md border border-neutral-800 p-3 space-y-5"> :class="message.admin_only ? 'border-amber-300/70' : 'border-neutral-800'">
<!-- Comment header --> <!-- Comment header -->
<div class="flex justify-between"> <div class="flex justify-between">
<p>{{ message.poster_name }}</p> <div class="flex">
<MemberCard :member-id="message.poster_id"></MemberCard>
<p v-if="message.admin_only" class="flex">
<Dot /><span class="text-amber-300">Internal</span>
</p>
</div>
<p class="text-muted-foreground">{{ new Date(message.post_time).toLocaleString("EN-us", { <p class="text-muted-foreground">{{ new Date(message.post_time).toLocaleString("EN-us", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit" minute: "2-digit"
}) }}</p> }) }}</p>
</div> </div>
<p>{{ message.post_content }}</p> <p>{{ message.post_content }}</p>
</div> </div>

View File

@@ -13,26 +13,38 @@ import Input from '@/components/ui/input/Input.vue';
import Textarea from '@/components/ui/textarea/Textarea.vue'; import Textarea from '@/components/ui/textarea/Textarea.vue';
import { toTypedSchema } from '@vee-validate/zod'; import { toTypedSchema } from '@vee-validate/zod';
import { Form } from 'vee-validate'; import { Form } from 'vee-validate';
import { onMounted, ref } from 'vue'; import { nextTick, onMounted, ref, watch } from 'vue';
import * as z from 'zod'; import * as z from 'zod';
import DateInput from '../form/DateInput.vue'; import DateInput from '../form/DateInput.vue';
import { ApplicationData } from '@/api/application'; import { ApplicationData } from '@shared/types/application';
import Dialog from '../ui/dialog/Dialog.vue';
import DialogTrigger from '../ui/dialog/DialogTrigger.vue';
import DialogContent from '../ui/dialog/DialogContent.vue';
import DialogHeader from '../ui/dialog/DialogHeader.vue';
import DialogTitle from '../ui/dialog/DialogTitle.vue';
import DialogDescription from '../ui/dialog/DialogDescription.vue';
import { getCoC } from '@/api/application';
import { startBrowserTracingPageLoadSpan } from '@sentry/vue';
const regexA = /^https?:\/\/steamcommunity\.com\/id\/[A-Za-z0-9_]+\/?$/;
const regexB = /^https?:\/\/steamcommunity\.com\/profiles\/\d+\/?$/;
const formSchema = toTypedSchema(z.object({ const formSchema = toTypedSchema(z.object({
dob: z.string().refine(v => v, { message: "A date of birth is required." }), dob: z.string().refine(v => v, { message: "A date of birth is required." }),
name: z.string(), name: z.string().nonempty(),
playtime: z.coerce.number({ invalid_type_error: "Must be a number", }).min(0, "Cannot be less than 0"), playtime: z.preprocess((v) => (v === "" ? undefined : String(v)),z.string({ required_error: "Required" }).regex(/^\d+(\.\d+)?$/, "Must be a number").transform(Number).refine((n) => n >= 0, "Cannot be less than 0")),
hobbies: z.string(), hobbies: z.string().nonempty(),
military: z.boolean(), military: z.boolean(),
communities: z.string(), communities: z.string().nonempty(),
joinReason: z.string(), joinReason: z.string().nonempty(),
milsimAttraction: z.string(), milsimAttraction: z.string().nonempty(),
referral: z.string(), referral: z.string().nonempty(),
steamProfile: z.string(), steamProfile: z.string().nonempty().refine((val) => regexA.test(val) || regexB.test(val), { message: "Invalid Steam profile URL." }),
timezone: z.string(), timezone: z.string().nonempty(),
canAttendSaturday: z.boolean(), canAttendSaturday: z.boolean(),
interests: z.string(), interests: z.string().nonempty(),
aknowledgeRules: z.literal(true, { acknowledgeRules: z.literal(true, {
errorMap: () => ({ message: "Required" }) errorMap: () => ({ message: "Required" })
}), }),
})) }))
@@ -41,7 +53,7 @@ const formSchema = toTypedSchema(z.object({
const fallbackInitials = { const fallbackInitials = {
military: false, military: false,
canAttendSaturday: false, canAttendSaturday: false,
aknowledgeRules: false, acknowledgeRules: false,
} }
const props = defineProps<{ const props = defineProps<{
@@ -57,7 +69,7 @@ async function onSubmit(val: any) {
emit('submit', val); emit('submit', val);
} }
onMounted(() => { onMounted(async () => {
if (props.data !== null) { if (props.data !== null) {
const parsed = typeof props.data === "string" const parsed = typeof props.data === "string"
? JSON.parse(props.data) ? JSON.parse(props.data)
@@ -67,8 +79,35 @@ onMounted(() => {
} else { } else {
initialValues.value = { ...fallbackInitials }; initialValues.value = { ...fallbackInitials };
} }
// CoCbox.value.innerHTML = await getCoC()
CoCString.value = await getCoC();
}) })
const showCoC = ref(false);
const CoCbox = ref<HTMLElement>();
const CoCString = ref<string>();
async function onDialogToggle(state: boolean) {
showCoC.value = state;
}
function enforceExternalLinks() {
if (!CoCbox.value) return;
const links = CoCbox.value.querySelectorAll("a");
links.forEach(a => {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
});
}
watch(() => showCoC.value, async () => {
if (showCoC) {
await nextTick(); // wait for v-html to update
enforceExternalLinks();
}
});
</script> </script>
@@ -82,7 +121,9 @@ onMounted(() => {
<FormControl> <FormControl>
<DateInput :model-value="(value as string) ?? ''" :disabled="readOnly" @update:model-value="handleChange" /> <DateInput :model-value="(value as string) ?? ''" :disabled="readOnly" @update:model-value="handleChange" />
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -94,7 +135,9 @@ onMounted(() => {
<FormControl> <FormControl>
<Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -105,7 +148,9 @@ onMounted(() => {
<FormControl> <FormControl>
<Input type="number" :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Input type="number" :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -117,7 +162,9 @@ onMounted(() => {
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange" <Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -127,11 +174,13 @@ onMounted(() => {
<FormLabel>Have you ever served in the military?</FormLabel> <FormLabel>Have you ever served in the military?</FormLabel>
<FormControl> <FormControl>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox :checked="value ?? false" @update:checked="handleChange" :disabled="readOnly" /> <Checkbox :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
<span>Yes (checked) / No (unchecked)</span> <span>Yes (checked) / No (unchecked)</span>
</div> </div>
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -142,7 +191,9 @@ onMounted(() => {
<FormControl> <FormControl>
<Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -154,7 +205,9 @@ onMounted(() => {
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange" <Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -166,7 +219,9 @@ onMounted(() => {
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange" <Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -178,7 +233,9 @@ onMounted(() => {
<Input placeholder="e.g., Reddit / Member: Alice" :model-value="value" @update:model-value="handleChange" <Input placeholder="e.g., Reddit / Member: Alice" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -194,7 +251,9 @@ onMounted(() => {
<Input type="url" placeholder="https://steamcommunity.com/profiles/7656119..." :model-value="value" <Input type="url" placeholder="https://steamcommunity.com/profiles/7656119..." :model-value="value"
@update:model-value="handleChange" :disabled="readOnly" /> @update:model-value="handleChange" :disabled="readOnly" />
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -206,7 +265,9 @@ onMounted(() => {
<Input placeholder="e.g., AEST, EST, UTC+10" :model-value="value" @update:model-value="handleChange" <Input placeholder="e.g., AEST, EST, UTC+10" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -220,7 +281,9 @@ onMounted(() => {
<span>Yes (checked) / No (unchecked)</span> <span>Yes (checked) / No (unchecked)</span>
</div> </div>
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -232,27 +295,44 @@ onMounted(() => {
<Input placeholder="e.g., Rifleman; Medic; Pilot" :model-value="value" @update:model-value="handleChange" <Input placeholder="e.g., Rifleman; Medic; Pilot" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
<!-- Code of Conduct (boolean, field name kept as-is) --> <!-- Code of Conduct (boolean, field name kept as-is) -->
<FormField name="aknowledgeRules" v-slot="{ value, handleChange }"> <FormField name="acknowledgeRules" v-slot="{ value, handleChange }">
<FormItem> <FormItem>
<FormLabel>Community Code of Conduct</FormLabel> <FormLabel>Community Code of Conduct</FormLabel>
<FormControl> <FormControl>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Checkbox :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
<span>By checking this box, you accept the <Button variant="link" class="p-0">Code of <span>By checking this box, you accept the <Button variant="link" class="p-0 h-min"
@click.prevent.stop="showCoC = true">Code of
Conduct</Button>.</span> Conduct</Button>.</span>
</div> </div>
</FormControl> </FormControl>
<FormMessage /> <div class="h-4">
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
<div class="pt-2" v-if="!readOnly"> <div class="pt-2" v-if="!readOnly">
<Button type="submit" :onClick="() => console.log('hi')" :disabled="readOnly">Submit Application</Button> <Button type="submit" :disabled="readOnly">Submit Application</Button>
</div> </div>
<Dialog :open="showCoC" @update:open="onDialogToggle">
<DialogContent class="sm:max-w-fit">
<DialogHeader>
<DialogTitle>Community Code of Conduct</DialogTitle>
<DialogDescription class="w-full max-h-[75vh] overflow-y-auto scrollbar-themed">
<div v-html="CoCString" ref="CoCbox" class="bookstack-container w-full"></div>
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
</Form> </Form>
</template> </template>

View File

@@ -12,6 +12,8 @@ import DropdownMenuTrigger from '../ui/dropdown-menu/DropdownMenuTrigger.vue';
import DropdownMenuContent from '../ui/dropdown-menu/DropdownMenuContent.vue'; import DropdownMenuContent from '../ui/dropdown-menu/DropdownMenuContent.vue';
import DropdownMenuItem from '../ui/dropdown-menu/DropdownMenuItem.vue'; import DropdownMenuItem from '../ui/dropdown-menu/DropdownMenuItem.vue';
import { Calendar } from 'lucide-vue-next'; import { Calendar } from 'lucide-vue-next';
import MemberCard from '../members/MemberCard.vue';
import Spinner from '../ui/spinner/Spinner.vue';
const route = useRoute(); const route = useRoute();
@@ -62,11 +64,11 @@ const maybe = computed<CalendarSignup[]>(() => { return activeEvent.value.eventS
const declined = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.NotAttending) }) const declined = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.NotAttending) })
const viewedState = ref<CalendarAttendance>(CalendarAttendance.Attending); const viewedState = ref<CalendarAttendance>(CalendarAttendance.Attending);
let user = useUserStore(); let userStore = useUserStore();
const myAttendance = computed<CalendarSignup | null>(() => { const myAttendance = computed<CalendarSignup | null>(() => {
if (!user.isLoggedIn) return null; if (!userStore.isLoggedIn) return null;
return activeEvent.value.eventSignups.find( return activeEvent.value.eventSignups.find(
(s) => s.member_id === user.user.id (s) => s.member_id === userStore.user.member.member_id
) || null; ) || null;
}); });
@@ -77,8 +79,8 @@ async function setAttendance(state: CalendarAttendance) {
} }
const canEditEvent = computed(() => { const canEditEvent = computed(() => {
if (!user.isLoggedIn) return false; if (!userStore.isLoggedIn) return false;
if (user.user.id == activeEvent.value.creator_id) if (userStore.user.member.member_id == activeEvent.value.creator_id)
return true; return true;
}); });
@@ -102,11 +104,11 @@ const attendanceTab = ref<"Alpha" | "Echo" | "Other">("Alpha");
const attendanceList = computed<CalendarSignup[]>(() => { const attendanceList = computed<CalendarSignup[]>(() => {
let out: CalendarSignup[] = []; let out: CalendarSignup[] = [];
if (attendanceTab.value === 'Alpha') { if (attendanceTab.value === 'Alpha') {
out = activeEvent.value.eventSignups?.filter((s) => s.member_unit === 'Alpha Company'); out = activeEvent.value.eventSignups?.filter((s) => s.unit_name === 'Alpha Company');
} else if (attendanceTab.value === 'Echo') { } else if (attendanceTab.value === 'Echo') {
out = activeEvent.value.eventSignups?.filter((s) => s.member_unit === 'Echo Company') out = activeEvent.value.eventSignups?.filter((s) => s.unit_name === 'Echo Company')
} else { } else {
out = activeEvent.value.eventSignups?.filter((s) => s.member_unit != 'Alpha Company' && s.member_unit != 'Echo Company') out = activeEvent.value.eventSignups?.filter((s) => s.unit_name != 'Alpha Company' && s.unit_name != 'Echo Company')
} }
const statusOrder: Record<CalendarAttendance, number> = { const statusOrder: Record<CalendarAttendance, number> = {
@@ -124,11 +126,11 @@ const attendanceCountsByGroup = computed(() => {
const signups = activeEvent.value.eventSignups ?? []; const signups = activeEvent.value.eventSignups ?? [];
return { return {
Alpha: signups.filter(s => s.member_unit === "Alpha Company").length, Alpha: signups.filter(s => s.unit_name === "Alpha Company").length,
Echo: signups.filter(s => s.member_unit === "Echo Company").length, Echo: signups.filter(s => s.unit_name === "Echo Company").length,
Other: signups.filter(s => Other: signups.filter(s =>
s.member_unit !== "Alpha Company" && s.unit_name !== "Alpha Company" &&
s.member_unit !== "Echo Company" s.unit_name !== "Echo Company"
).length, ).length,
}; };
}); });
@@ -213,7 +215,7 @@ defineExpose({ forceReload })
<CircleAlert></CircleAlert> This event has been cancelled <CircleAlert></CircleAlert> This event has been cancelled
</div> </div>
</section> </section>
<section v-if="isPast && user.isLoggedIn" class="w-full"> <section v-if="isPast && userStore.isLoggedIn" class="w-full">
<ButtonGroup class="flex w-full"> <ButtonGroup class="flex w-full">
<Button variant="outline" <Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''" :class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''"
@@ -239,7 +241,8 @@ defineExpose({ forceReload })
<MapPin :size="20"></MapPin> {{ activeEvent.location || "Unknown" }} <MapPin :size="20"></MapPin> {{ activeEvent.location || "Unknown" }}
</div> </div>
<div class="text-foreground/80 flex gap-3 items-center"> <div class="text-foreground/80 flex gap-3 items-center">
<User :size="20"></User> {{ activeEvent.creator_name || "Unknown User" }} <User :size="20"></User>
<MemberCard :member-id="activeEvent.creator_id"></MemberCard>
</div> </div>
</section> </section>
<!-- Description --> <!-- Description -->
@@ -276,7 +279,9 @@ defineExpose({ forceReload })
<div v-for="person in attendanceList" :key="person.member_id" <div v-for="person in attendanceList" :key="person.member_id"
class="grid grid-cols-2 py-1 *:px-3 hover:bg-muted"> class="grid grid-cols-2 py-1 *:px-3 hover:bg-muted">
<p>{{ person.member_name }}</p> <div>
<MemberCard :member-id="person.member_id"></MemberCard>
</div>
<p :class="statusColor(person.status)" class="text-right"> <p :class="statusColor(person.status)" class="text-right">
{{ displayStatus(person.status) }} {{ displayStatus(person.status) }}
</p> </p>
@@ -286,4 +291,13 @@ defineExpose({ forceReload })
</section> </section>
</div> </div>
</div> </div>
<div v-else class="flex justify-center h-full items-center">
<button
class="absolute top-4 right-4 inline-flex flex-none size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition cursor-pointer z-50"
aria-label="Close" @click="emit('close')">
<X class="size-4" />
</button>
<Spinner class="size-8"></Spinner>
</div>
</template> </template>

View File

@@ -1,26 +1,48 @@
<script setup lang="ts"> <script setup lang="ts">
import { Check, Search } from "lucide-vue-next" import { Check, Search } from "lucide-vue-next"
import { Combobox, ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox" import { ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox"
import { onMounted, ref } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { Member, getMembers } from "@/api/member"; import { getMembers } from "@/api/member";
import { Member } from "@shared/types/member";
import Button from "@/components/ui/button/Button.vue"; import Button from "@/components/ui/button/Button.vue";
import { import {
CalendarDate, CalendarDate,
DateFormatter, DateFormatter,
fromDate,
getLocalTimeZone, getLocalTimeZone,
parseDate,
today,
} from "@internationalized/date" } from "@internationalized/date"
import type { DateRange } from "reka-ui" import type { DateRange, DateValue } from "reka-ui"
import type { Ref } from "vue" import type { Ref } from "vue"
import Popover from "@/components/ui/popover/Popover.vue"; import Popover from "@/components/ui/popover/Popover.vue";
import PopoverTrigger from "@/components/ui/popover/PopoverTrigger.vue"; import PopoverTrigger from "@/components/ui/popover/PopoverTrigger.vue";
import PopoverContent from "@/components/ui/popover/PopoverContent.vue"; import PopoverContent from "@/components/ui/popover/PopoverContent.vue";
import { RangeCalendar } from "@/components/ui/range-calendar"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CalendarIcon } from "lucide-vue-next" import { CalendarIcon } from "lucide-vue-next"
import Textarea from "@/components/ui/textarea/Textarea.vue"; import Textarea from "@/components/ui/textarea/Textarea.vue";
import { LOARequest, submitLOA } from "@/api/loa"; // <-- import the submit function import { adminSubmitLOA, getLoaPolicy, getLoaTypes, submitLOA } from "@/api/loa"; // <-- import the submit function
import { LOARequest, LOAType } from "@shared/types/loa";
import { useForm, Field as VeeField } from "vee-validate";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from '@/components/ui/field'
import Combobox from "../ui/combobox/Combobox.vue";
import Select from "../ui/select/Select.vue";
import SelectTrigger from "../ui/select/SelectTrigger.vue";
import SelectValue from "../ui/select/SelectValue.vue";
import SelectContent from "../ui/select/SelectContent.vue";
import SelectItem from "../ui/select/SelectItem.vue";
import FieldError from "../ui/field/FieldError.vue";
const members = ref<Member[]>([]) const members = ref<Member[]>([])
const loaTypes = ref<LOAType[]>();
const policyString = ref<string | null>(null);
const currentMember = ref<Member | null>(null); const currentMember = ref<Member | null>(null);
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@@ -31,142 +53,271 @@ const props = withDefaults(defineProps<{
member: null, member: null,
}); });
const df = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const df = new DateFormatter("en-US", { const userStore = useUserStore()
dateStyle: "medium",
//form stuff
import { loaSchema } from '@shared/schemas/loaSchema'
import { toTypedSchema } from "@vee-validate/zod";
import Calendar from "../ui/calendar/Calendar.vue";
import { useUserStore } from "@/stores/user";
const { handleSubmit, values, resetForm } = useForm({
validationSchema: toTypedSchema(loaSchema),
}) })
const value = ref({ const formSubmitted = ref(false);
// start: new CalendarDate(2022, 1, 20),
// end: new CalendarDate(2022, 1, 20).add({ days: 20 }),
}) as Ref<DateRange>
const reason = ref(""); // <-- reason for LOA const onSubmit = handleSubmit(async (values) => {
const submitting = ref(false); console.log(values);
const submitError = ref<string | null>(null); const out: LOARequest = {
const submitSuccess = ref(false); member_id: values.member_id,
start_date: values.start_date,
end_date: values.end_date,
type_id: values.type.id,
reason: values.reason
};
if (props.adminMode) {
await adminSubmitLOA(out);
} else {
await submitLOA(out);
userStore.loadUser();
}
formSubmitted.value = true;
})
onMounted(async () => { onMounted(async () => {
if (props.member) { if (props.member) {
currentMember.value = props.member; currentMember.value = props.member;
} }
try {
if (!props.adminMode) {
let policy = await getLoaPolicy() as any;
policyString.value = policy;
policyRef.value.innerHTML = policyString.value;
}
} catch (error) {
console.error(error);
}
if (props.adminMode) { if (props.adminMode) {
members.value = await getMembers(); members.value = await getMembers();
} else {
members.value.push(props.member);
} }
members.value = await getMembers(); loaTypes.value = await getLoaTypes();
resetForm({ values: { member_id: currentMember.value?.member_id } });
}); });
// Submit handler const policyRef = ref<HTMLElement>(null);
async function handleSubmit() {
submitError.value = null;
submitSuccess.value = false;
submitting.value = true;
// Use currentMember if adminMode, otherwise use your own member id (stubbed as 89 here) const defaultPlaceholder = today(getLocalTimeZone())
const member_id = currentMember.value?.member_id ?? 89;
// Format dates as ISO strings const minEndDate = computed(() => {
const filed_date = toMariaDBDatetime(new Date()); if (values.start_date) {
const start_date = toMariaDBDatetime(value.value.start?.toDate(getLocalTimeZone())); return new CalendarDate(values.start_date.getFullYear(), values.start_date.getMonth() + 1, values.start_date.getDate())
const end_date = toMariaDBDatetime(value.value.end?.toDate(getLocalTimeZone()));
if (!member_id || !filed_date || !start_date || !end_date) {
submitError.value = "Missing required fields";
submitting.value = false;
return;
}
const req: LOARequest = {
filed_date,
start_date,
end_date,
reason: reason.value,
member_id
};
const result = await submitLOA(req);
submitting.value = false;
if (result.id) {
submitSuccess.value = true;
reason.value = "";
} else { } else {
submitError.value = result.error || "Failed to submit LOA"; return null;
} }
} })
function toMariaDBDatetime(date: Date): string { const maxEndDate = computed(() => {
return date.toISOString().slice(0, 19).replace('T', ' '); if (values.type && values.start_date) {
} let endDateObj = new Date(values.start_date.getTime() + values.type.max_length_days * 24 * 60 * 60 * 1000);
return new CalendarDate(endDateObj.getFullYear(), endDateObj.getMonth() + 1, endDateObj.getDate())
} else {
return null;
}
})
const memberFilter = ref('');
const filteredMembers = computed(() => {
const q = memberFilter?.value?.toLowerCase() ?? ""
const results: Member[] = []
for (const m of members.value ?? []) {
if (!q || (m.displayName || m.member_name).toLowerCase().includes(q)) {
results.push(m)
if (results.length >= 50) break
}
}
return results
})
</script> </script>
<template> <template>
<div class="flex flex-row-reverse gap-6 mx-auto w-full" :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 flex-col space-x-4 rounded-md border p-4">
<div class="flex-2 space-y-1"> <p class="scroll-m-20 text-2xl font-semibold tracking-tight">LOA Policy</p>
<p class="text-sm font-medium leading-none"> <div ref="policyRef" class="bookstack-container">
LOA Policy <!-- LOA policy gets loaded here -->
</p>
<p class="text-sm text-muted-foreground">
Policy goes here.
</p>
</div> </div>
</div> </div>
<div class="flex-1 flex flex-col gap-5"> <div class="flex-1 flex flex-col gap-5">
<div class="flex w-full gap-5 "> <form v-if="!formSubmitted" @submit="onSubmit" class="flex flex-col gap-2">
<Combobox class="w-1/2" v-model="currentMember" :disabled="!adminMode"> <div class="flex w-full gap-5">
<ComboboxAnchor class="w-full"> <VeeField v-slot="{ field, errors }" name="member_id">
<ComboboxInput placeholder="Search members..." class="w-full pl-9" <Field>
:display-value="(v) => v ? v.member_name : ''" /> <FieldContent>
</ComboboxAnchor> <FieldLabel>Member</FieldLabel>
<ComboboxList class="w-full"> <Combobox :model-value="field.value" @update:model-value="field.onChange"
<ComboboxEmpty class="text-muted-foreground">No results</ComboboxEmpty> :disabled="!adminMode">
<ComboboxGroup> <ComboboxAnchor class="w-full">
<template v-for="member in members" :key="member.member_id"> <ComboboxInput placeholder="Search members..." class="w-full pl-3"
<ComboboxItem :value="member" :display-value="(id) => {
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5"> const m = members.find(mem => mem.member_id === id)
{{ member.member_name }} return m ? m.displayName || m.member_name : ''
<ComboboxItemIndicator class="absolute left-2 inline-flex items-center"> }" @input="memberFilter = $event.target.value" />
<Check class="h-4 w-4" /> </ComboboxAnchor>
</ComboboxItemIndicator> <ComboboxList class="*:w-64">
</ComboboxItem> <ComboboxEmpty class="text-muted-foreground w-full">No results</ComboboxEmpty>
</template> <ComboboxGroup>
</ComboboxGroup> <div class="max-h-80 overflow-y-scroll scrollbar-themed min-w-3xs">
</ComboboxList> <template v-for="member in filteredMembers" :key="member.member_id">
</Combobox> <ComboboxItem :value="member.member_id"
<Popover> class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5 w-full">
<PopoverTrigger as-child> {{ member.displayName || member.member_name }}
<Button variant="outline" :class="cn( <ComboboxItemIndicator
'w-1/2 justify-start text-left font-normal', class="absolute left-2 inline-flex items-center">
!value && 'text-muted-foreground', <Check class="h-4 w-4" />
)"> </ComboboxItemIndicator>
<CalendarIcon class="mr-2 h-4 w-4" /> </ComboboxItem>
<template v-if="value.start"> </template>
<template v-if="value.end"> </div>
{{ df.format(value.start.toDate(getLocalTimeZone())) }} - {{ </ComboboxGroup>
df.format(value.end.toDate(getLocalTimeZone())) }} </ComboboxList>
</template> </Combobox>
<template v-else> <div class="h-4">
{{ df.format(value.start.toDate(getLocalTimeZone())) }} <FieldError v-if="errors.length" :errors="errors"></FieldError>
</template> </div>
</template> </FieldContent>
<template v-else> </Field>
Pick a date </VeeField>
</template> <VeeField v-slot="{ field, errors }" name="type">
</Button> <Field class="w-full">
</PopoverTrigger> <FieldContent>
<PopoverContent class="w-auto p-0"> <FieldLabel>Type</FieldLabel>
<RangeCalendar v-model="value" initial-focus :number-of-months="2" <Select :model-value="field.value" @update:model-value="field.onChange">
@update:start-value="(startDate) => value.start = startDate" /> <SelectTrigger class="w-full">
</PopoverContent> <SelectValue placeholder="Select type"></SelectValue>
</Popover> </SelectTrigger>
<SelectContent>
<SelectItem v-for="type in loaTypes" :value="type">
{{ type.name }}
</SelectItem>
</SelectContent>
</Select>
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors"></FieldError>
</div>
</FieldContent>
</Field>
</VeeField>
</div>
<div class="flex gap-5">
<VeeField v-slot="{ field, errors }" name="start_date">
<Field>
<FieldContent>
<FieldLabel>Start Date</FieldLabel>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" :class="cn(
'w-full justify-start text-left font-normal',
!field.value && 'text-muted-foreground',
)">
<CalendarIcon class="mr-2 h-4 w-4" />
{{ field.value ? df.format(field.value) : "Pick a date" }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar
:model-value="field.value
? new CalendarDate(field.value.getFullYear(), field.value.getMonth() + 1, field.value.getDate()) : null"
@update:model-value="(val: CalendarDate) => field.onChange(val.toDate(getLocalTimeZone()))"
layout="month-and-year" :min-value="today(getLocalTimeZone())" />
</PopoverContent>
</Popover>
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors"></FieldError>
</div>
</FieldContent>
</Field>
</VeeField>
<VeeField v-slot="{ field, errors }" name="end_date">
<Field>
<FieldContent>
<FieldLabel>End Date</FieldLabel>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" :class="cn(
'w-full justify-start text-left font-normal',
!field.value && 'text-muted-foreground',
)">
<CalendarIcon class="mr-2 h-4 w-4" />
{{ field.value ? df.format(field.value) : "Pick a date" }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar
:model-value="field.value ? new CalendarDate(field.value.getFullYear(), field.value.getMonth() + 1, field.value.getDate()) : null"
@update:model-value="(val: CalendarDate) => field.onChange(val.toDate(getLocalTimeZone()))"
:default-placeholder="defaultPlaceholder" :min-value="minEndDate"
:max-value="maxEndDate" layout="month-and-year">
</Calendar>
</PopoverContent>
</Popover>
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors"></FieldError>
</div>
</FieldContent>
</Field>
</VeeField>
</div>
<div>
<VeeField v-slot="{ field, errors }" name="reason">
<Field>
<FieldContent>
<FieldLabel>Reason</FieldLabel>
<Textarea :model-value="field.value" @update:model-value="field.onChange"
placeholder="Reason for LOA" class="resize-none h-28"></Textarea>
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors"></FieldError>
</div>
</FieldContent>
</Field>
</VeeField>
</div>
<div class="flex justify-end">
<Button type="submit">Submit</Button>
</div>
</form>
<div v-else class="flex flex-col gap-4 py-8 text-left">
<h2 class="text-xl font-semibold">
LOA Request Submitted
</h2>
<p class="max-w-md text-muted-foreground">
{{ adminMode ? 'You have successfully submitted a Leave of Absence on behalf of another member.' :
`Your Leave
of Absence request has been submitted successfully.
It will take effect on your selected start date.` }}
</p>
<div class="flex gap-3 mt-4">
<Button variant="secondary" @click="formSubmitted = false">
Submit another request
</Button>
</div>
</div> </div>
<Textarea v-model="reason" placeholder="Reason for LOA" class="w-full resize-none" />
<div class="flex justify-end">
<Button :onClick="handleSubmit" :disabled="submitting" class="w-min">Submit</Button>
</div>
<div v-if="submitError" class="text-red-500 text-sm mt-2">{{ submitError }}</div>
<div v-if="submitSuccess" class="text-green-500 text-sm mt-2">LOA submitted successfully!</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -15,31 +15,68 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Ellipsis } from "lucide-vue-next"; import { ChevronDown, ChevronUp, Ellipsis, X } from "lucide-vue-next";
import { getAllLOAs, LOARequest } from "@/api/loa"; import { cancelLOA, extendLOA, getAllLOAs, getMyLOAs } from "@/api/loa";
import { onMounted, ref, computed } from "vue"; import { onMounted, ref, computed } from "vue";
import { LOARequest } from "@shared/types/loa";
import Dialog from "../ui/dialog/Dialog.vue";
import DialogTrigger from "../ui/dialog/DialogTrigger.vue";
import DialogContent from "../ui/dialog/DialogContent.vue";
import DialogHeader from "../ui/dialog/DialogHeader.vue";
import DialogTitle from "../ui/dialog/DialogTitle.vue";
import DialogDescription from "../ui/dialog/DialogDescription.vue";
import Button from "../ui/button/Button.vue";
import Calendar from "../ui/calendar/Calendar.vue";
import {
CalendarDate,
getLocalTimeZone,
} from "@internationalized/date"
import { el } from "@fullcalendar/core/internal-common";
import MemberCard from "../members/MemberCard.vue";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import { pagination } from "@shared/types/pagination";
const props = defineProps<{
adminMode?: boolean
}>()
const LOAList = ref<LOARequest[]>([]); const LOAList = ref<LOARequest[]>([]);
onMounted(async () => { onMounted(async () => {
LOAList.value = await getAllLOAs(); await loadLOAs();
}); });
function formatDate(dateStr: string): string { async function loadLOAs() {
if (!dateStr) return ""; if (props.adminMode) {
return new Date(dateStr).toLocaleDateString("en-US", { let result = await getAllLOAs(pageNum.value, pageSize.value);
LOAList.value = result.data;
pageData.value = result.pagination;
} else {
let result = await getMyLOAs(pageNum.value, pageSize.value);
LOAList.value = result.data;
pageData.value = result.pagination;
}
}
function formatDate(date: Date): string {
if (!date) return "";
date = typeof date === 'string' ? new Date(date) : date;
return date.toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
}); });
} }
function loaStatus(loa: { function loaStatus(loa: LOARequest): "Upcoming" | "Active" | "Overdue" | "Closed" {
start_date: string; if (loa.closed) return "Closed";
end_date: string;
deleted?: number;
}): "Upcoming" | "Active" | "Expired" | "Cancelled" {
if (loa.deleted) return "Cancelled";
const now = new Date(); const now = new Date();
const start = new Date(loa.start_date); const start = new Date(loa.start_date);
@@ -47,61 +84,203 @@ function loaStatus(loa: {
if (now < start) return "Upcoming"; if (now < start) return "Upcoming";
if (now >= start && now <= end) return "Active"; if (now >= start && now <= end) return "Active";
if (now > end) return "Expired"; if (now > end) return "Overdue";
return "Expired"; // fallback return "Overdue"; // fallback
} }
function sortByStartDate(loas: LOARequest[]): LOARequest[] { async function cancelAndReload(id: number) {
return [...loas].sort( await cancelLOA(id, props.adminMode);
(a, b) => new Date(b.start_date).getTime() - new Date(a.start_date).getTime() await loadLOAs();
);
} }
const sortedLoas = computed(() => sortByStartDate(LOAList.value)); const isExtending = ref(false);
const targetLOA = ref<LOARequest | null>(null);
const extendTo = ref<CalendarDate | null>(null);
const targetEnd = computed(() => { return targetLOA.value.extended_till ? targetLOA.value.extended_till : targetLOA.value.end_date })
function toCalendarDate(date: Date): CalendarDate {
if (typeof date === 'string')
date = new Date(date);
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
}
async function commitExtend() {
await extendLOA(targetLOA.value.id, extendTo.value.toDate(getLocalTimeZone()));
isExtending.value = false;
await loadLOAs();
}
const expanded = ref<number | null>(null);
const hoverID = ref<number | null>(null);
const pageNum = ref<number>(1);
const pageData = ref<pagination>();
const pageSize = ref<number>(15)
const pageSizeOptions = [10, 15, 30]
function setPageSize(size: number) {
pageSize.value = size
pageNum.value = 1;
loadLOAs();
}
function setPage(pagenum: number) {
pageNum.value = pagenum;
loadLOAs();
}
</script> </script>
<template> <template>
<div class="w-5xl mx-auto"> <div>
<Table> <Dialog :open="isExtending" @update:open="(val) => isExtending = val">
<TableHeader> <DialogContent>
<TableRow> <DialogHeader>
<TableHead class="w-[100px]">Member</TableHead> <DialogTitle>Extend {{ targetLOA.name }}'s Leave of Absence </DialogTitle>
<TableHead>Start</TableHead> </DialogHeader>
<TableHead>End</TableHead> <div class="flex gap-5">
<TableHead>Reason</TableHead> <Calendar v-model="extendTo" class="rounded-md border shadow-sm w-min" layout="month-and-year"
<TableHead>Posted on</TableHead> :min-value="toCalendarDate(targetEnd)"
<TableHead>Status</TableHead> :max-value="toCalendarDate(targetEnd).add({ years: 1 })" />
</TableRow> <div class="flex flex-col w-full gap-3 px-2">
</TableHeader> <p>Quick Options</p>
<TableBody> <Button variant="outline" @click="extendTo = toCalendarDate(targetEnd).add({ days: 7 })">1
<TableRow v-for="post in sortedLoas" :key="post.id" class="hover:bg-muted/50"> Week</Button>
<TableCell class="font-medium"> <Button variant="outline" @click="extendTo = toCalendarDate(targetEnd).add({ months: 1 })">1
{{ post.name }} Month</Button>
</TableCell> </div>
<TableCell>{{ formatDate(post.start_date) }}</TableCell> </div>
<TableCell>{{ formatDate(post.end_date) }}</TableCell> <div class="flex justify-end gap-4">
<TableCell>{{ post.reason }}</TableCell> <Button variant="outline" @click="isExtending = false">Cancel</Button>
<TableCell>{{ formatDate(post.filed_date) }}</TableCell> <Button @click="commitExtend">Extend</Button>
<TableCell> </div>
<Badge v-if="loaStatus(post) === 'Upcoming'" class="bg-blue-500">Upcoming</Badge> </DialogContent>
<Badge v-else-if="loaStatus(post) === 'Active'" class="bg-green-500">Active</Badge> </Dialog>
<Badge v-else-if="loaStatus(post) === 'Expired'" class="bg-gray-400">Expired</Badge> <div class="max-w-7xl w-full mx-auto">
<Badge v-else class="bg-red-500">Cancelled</Badge> <Table>
</TableCell> <TableHeader>
<TableCell @click.stop="console.log('hi')" class="text-right"> <TableRow>
<DropdownMenu> <TableHead>Member</TableHead>
<DropdownMenuTrigger class="cursor-pointer"> <TableHead>Type</TableHead>
<Ellipsis></Ellipsis> <TableHead>Start</TableHead>
</DropdownMenuTrigger> <TableHead>End</TableHead>
<DropdownMenuContent> <!-- <TableHead class="w-[500px]">Reason</TableHead> -->
<DropdownMenuItem :variant="'destructive'">Cancel</DropdownMenuItem> <TableHead>Posted on</TableHead>
</DropdownMenuContent> <TableHead>Status</TableHead>
</DropdownMenu> </TableRow>
</TableCell> </TableHeader>
<TableBody>
<template v-for="post in LOAList" :key="post.id">
<TableRow class="hover:bg-muted/50 cursor-pointer" @click="expanded = post.id"
@mouseenter="hoverID = post.id" @mouseleave="hoverID = null" :class="{
'border-b-0': expanded === post.id,
'bg-muted/50': hoverID === post.id
}">
<TableCell class="font-medium">
<MemberCard :member-id="post.member_id"></MemberCard>
</TableCell>
<TableCell>{{ post.type_name }}</TableCell>
<TableCell>{{ formatDate(post.start_date) }}</TableCell>
<TableCell>{{ post.extended_till ? formatDate(post.extended_till) :
formatDate(post.end_date) }}
</TableCell>
<!-- <TableCell>{{ post.reason }}</TableCell> -->
<TableCell>{{ formatDate(post.filed_date) }}</TableCell>
<TableCell>
<Badge v-if="loaStatus(post) === 'Upcoming'" class="bg-blue-400">Upcoming</Badge>
<Badge v-else-if="loaStatus(post) === 'Active'" class="bg-green-500">Active</Badge>
<Badge v-else-if="loaStatus(post) === 'Overdue'" class="bg-yellow-400">Overdue</Badge>
<Badge v-else class="bg-gray-400">Ended</Badge>
</TableCell>
<TableCell @click.stop="" class="text-right">
<DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer">
<Button variant="ghost">
<Ellipsis class="size-6"></Ellipsis>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem v-if="!post.closed && props.adminMode"
@click="isExtending = true; targetLOA = post">
Extend
</DropdownMenuItem>
<DropdownMenuItem v-if="!post.closed" :variant="'destructive'"
@click="cancelAndReload(post.id)">{{ loaStatus(post) === 'Upcoming' ?
'Cancel' :
'End' }}
</DropdownMenuItem>
<!-- Fallback: no actions available -->
<p v-if="post.closed || (!props.adminMode && post.closed)"
class="p-2 text-center text-sm">
No actions
</p>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
<TableCell>
<Button v-if="expanded === post.id" @click.stop="expanded = null" variant="ghost">
<ChevronUp class="size-6" />
</Button>
<Button v-else @click.stop="expanded = post.id" variant="ghost">
<ChevronDown class="size-6" />
</Button>
</TableCell>
</TableRow>
<TableRow v-if="expanded === post.id" @mouseenter="hoverID = post.id"
@mouseleave="hoverID = null" :class="{ 'bg-muted/50 border-t-0': hoverID === post.id }">
<TableCell :colspan="8" class="p-0">
<div class="w-full p-3 mb-6 space-y-3">
<div class="flex justify-between items-start gap-4">
<div class="flex-1">
<!-- Title -->
<p class="text-md font-semibold text-foreground">
Reason
</p>
</TableRow> <!-- Content -->
</TableBody> <p
</Table> class="mt-1 text-md whitespace-pre-wrap leading-relaxed text-muted-foreground">
{{ post.reason }}
</p>
</div>
</div>
</div>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
<div class="mt-5 flex justify-between">
<div></div>
<Pagination v-slot="{ page }" :items-per-page="pageData?.pageSize || 10" :total="pageData?.total || 10"
:default-page="2" :page="pageNum" @update:page="setPage">
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template v-for="(item, index) in items" :key="index">
<PaginationItem v-if="item.type === 'page'" :value="item.value"
:is-active="item.value === page">
{{ item.value }}
</PaginationItem>
</template>
<PaginationEllipsis :index="4" />
<PaginationNext />
</PaginationContent>
</Pagination>
<div class="flex items-center gap-3 text-sm">
<p class="text-muted-foreground text-nowrap">Per page:</p>
<button v-for="size in pageSizeOptions" :key="size" @click="setPageSize(size)"
class="px-2 py-1 rounded transition-colors" :class="{
'bg-muted font-semibold': pageSize === size,
'hover:bg-muted/50': pageSize !== size
}">
{{ size }}
</button>
</div>
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { useMemberDirectory } from '@/stores/memberDirectory';
import { ref, onMounted, computed } from 'vue';
import { Member, type MemberLight } from '@shared/types/member'
import Popover from '../ui/popover/Popover.vue';
import PopoverTrigger from '../ui/popover/PopoverTrigger.vue';
import PopoverContent from '../ui/popover/PopoverContent.vue';
import { cn } from '@/lib/utils.js'
import { watch } from 'vue';
import { format } from 'path';
import Spinner from '../ui/spinner/Spinner.vue';
// Props
const props = defineProps({
memberId: {
type: Number,
required: true
}
});
// Local state
const memberLight = ref<MemberLight | null>(null);
const memberFull = ref<Member | null>(null)
const loadingFull = ref(false)
const membersStore = useMemberDirectory();
// Fetch the light member data on mount
onMounted(async () => {
memberLight.value = await membersStore.getLight(props.memberId);
});
async function loadFull() {
if (memberFull.value || loadingFull.value) return
loadingFull.value = true
try {
memberFull.value = await membersStore.getFull(props.memberId)
} finally {
loadingFull.value = false
}
}
watch(() => props.memberId, async (newId) => {
memberLight.value = await membersStore.getLight(newId);
memberFull.value = null;
loadingFull.value = false;
});
// Compute display name (displayName fallback to username)
const displayName = computed(() => {
if (!memberLight.value) return props.memberId;
return memberLight.value.displayName || memberLight.value.username;
});
const DEFAULT_TEXT_COLOR = '#9ca3af' // muted gray for text
const DEFAULT_BG_COLOR = '#d1d5db22' // muted gray ~20% opacity
const textColor = computed(() => memberLight.value?.color || DEFAULT_TEXT_COLOR)
const bgColor = computed(() => (memberLight.value?.color ? `${memberLight.value.color}22` : DEFAULT_BG_COLOR))
const hasFullInfo = computed(() => {
if (!memberFull.value) return false
// check if any field has a value
const { rank, unit, status } = memberFull.value
return !!(rank || unit || status)
})
function formatDate(date: Date): string {
if (!date) return "";
date = typeof date === 'string' ? new Date(date) : date;
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
</script>
<template>
<Popover @update:open="open => open && loadFull()">
<PopoverTrigger @click.stop>
<p :class="cn(
'px-2 py-1 rounded font-medium inline-flex items-center cursor-pointer'
)" :style="{
color: textColor,
backgroundColor: bgColor
}">
{{ displayName }}
</p>
</PopoverTrigger>
<PopoverContent class="w-72 p-0 overflow-hidden">
<!-- Loading -->
<div v-if="loadingFull" class="p-4 text-sm text-muted-foreground mx-auto flex justify-center my-5">
<Spinner></Spinner>
</div>
<!-- Profile -->
<div v-else-if="memberFull">
<!-- Header -->
<div class="px-4 py-3 relative" :style="{ backgroundColor: `${memberLight?.color}22` }">
<!-- Display name / username -->
<div class="text-lg font-semibold leading-tight" :style="{ color: memberLight?.color }">
{{ displayName }}
</div>
<div v-if="memberLight.displayName" class="text-xs text-muted-foreground">
{{ memberLight?.username }}
</div>
</div>
<!-- Body -->
<div class="p-4 space-y-3 text-sm">
<!-- Full info -->
<template v-if="hasFullInfo">
<div v-if="memberFull.loa_until"
class=" rounded-md text-center bg-yellow-500/10 px-2 py-1 text-xs text-yellow-600">
On Leave of Absence until {{ formatDate(memberFull.loa_until) }}
</div>
<div v-if="memberFull.rank" class="flex justify-between">
<span class="text-muted-foreground">Rank</span>
<span class="font-medium">{{ memberFull.rank }}</span>
</div>
<div v-if="memberFull.unit" class="flex justify-between">
<span class="text-muted-foreground">Unit</span>
<span class="font-medium">{{ memberFull.unit }}</span>
</div>
<div v-if="memberFull.status" class="flex justify-between">
<span class="text-muted-foreground">Status</span>
<span class="font-medium">{{ memberFull.status }}</span>
</div>
</template>
<!-- No info fallback -->
<div v-else class="text-sm text-muted-foreground italic">
No user info
</div>
</div>
</div>
<!-- Not found -->
<div v-else class="p-4 text-sm text-muted-foreground">
Member not found
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -3,9 +3,10 @@ import { trainingReportSchema, courseEventAttendeeSchema } from '@shared/schemas
import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails } from '@shared/types/course' import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails } from '@shared/types/course'
import { useForm, useFieldArray, FieldArray as VeeFieldArray, ErrorMessage, Field as VeeField } from 'vee-validate' import { useForm, useFieldArray, FieldArray as VeeFieldArray, ErrorMessage, Field as VeeField } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import { computed, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { getAllAttendeeRoles, getAllTrainings, postTrainingReport } from '@/api/trainingReport' import { getAllAttendeeRoles, getAllTrainings, postTrainingReport } from '@/api/trainingReport'
import { getMembers, Member } from '@/api/member' import { getAllLightMembers, getLightMembers, getMembers } from '@/api/member'
import { Member, MemberLight } from '@shared/types/member'
import FieldGroup from '../ui/field/FieldGroup.vue' import FieldGroup from '../ui/field/FieldGroup.vue'
import Field from '../ui/field/Field.vue' import Field from '../ui/field/Field.vue'
@@ -13,12 +14,17 @@ import FieldLabel from '../ui/field/FieldLabel.vue'
import FieldError from '../ui/field/FieldError.vue' import FieldError from '../ui/field/FieldError.vue'
import Button from '../ui/button/Button.vue' import Button from '../ui/button/Button.vue'
import Textarea from '../ui/textarea/Textarea.vue' import Textarea from '../ui/textarea/Textarea.vue'
import { Plus, X } from 'lucide-vue-next'; import { Check, Plus, X } from 'lucide-vue-next';
import FieldSet from '../ui/field/FieldSet.vue' import FieldSet from '../ui/field/FieldSet.vue'
import FieldLegend from '../ui/field/FieldLegend.vue' import FieldLegend from '../ui/field/FieldLegend.vue'
import FieldDescription from '../ui/field/FieldDescription.vue' import FieldDescription from '../ui/field/FieldDescription.vue'
import Checkbox from '../ui/checkbox/Checkbox.vue' import Checkbox from '../ui/checkbox/Checkbox.vue'
import { configure } from 'vee-validate' import { configure } from 'vee-validate'
import { ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox"
import Popover from "@/components/ui/popover/Popover.vue";
import PopoverTrigger from "@/components/ui/popover/PopoverTrigger.vue";
import PopoverContent from "@/components/ui/popover/PopoverContent.vue";
import Combobox from '../ui/combobox/Combobox.vue'
const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({ const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({
@@ -79,16 +85,44 @@ const { remove, push, fields } = useFieldArray('attendees');
const selectedCourse = computed<Course | undefined>(() => { return trainings.value?.find(c => c.id == values.course_id) }) const selectedCourse = computed<Course | undefined>(() => { return trainings.value?.find(c => c.id == values.course_id) })
const trainings = ref<Course[] | null>(null); const trainings = ref<Course[] | null>(null);
const members = ref<Member[] | null>(null); const members = ref<MemberLight[] | null>(null);
const eventRoles = ref<CourseAttendeeRole[] | null>(null); const eventRoles = ref<CourseAttendeeRole[] | null>(null);
const emit = defineEmits(['submit']) const emit = defineEmits(['submit'])
onMounted(async () => { onMounted(async () => {
trainings.value = await getAllTrainings(); trainings.value = await getAllTrainings();
members.value = await getMembers(); members.value = await getAllLightMembers();
eventRoles.value = await getAllAttendeeRoles(); eventRoles.value = await getAllAttendeeRoles();
}) })
const selectCourse = ref(false);
const openMap = reactive<Record<string, boolean>>({})
const memberMap = computed(() =>
Object.fromEntries(
members.value?.map(m => [m.id, m.displayName || m.username]) ?? []
)
)
const memberSearch = ref('')
const MAX_RESULTS = 50
const filteredMembers = computed(() => {
const q = memberSearch?.value?.toLowerCase() ?? ""
const results: MemberLight[] = []
for (const m of members.value ?? []) {
if (!q || (m.displayName || m.username).toLowerCase().includes(q)) {
results.push(m)
if (results.length >= MAX_RESULTS) break
}
}
return results
})
</script> </script>
<template> <template>
<form id="trainingForm" @submit.prevent="submitForm" class="flex flex-col gap-5"> <form id="trainingForm" @submit.prevent="submitForm" class="flex flex-col gap-5">
@@ -99,13 +133,37 @@ onMounted(async () => {
<VeeField v-slot="{ field, errors }" name="course_id"> <VeeField v-slot="{ field, errors }" name="course_id">
<Field :data-invalid="!!errors.length"> <Field :data-invalid="!!errors.length">
<FieldLabel class="scroll-m-20 text-lg tracking-tight">Training Course</FieldLabel> <FieldLabel class="scroll-m-20 text-lg tracking-tight">Training Course</FieldLabel>
<select v-bind="field" <Combobox :model-value="field.value" @update:open="selectCourse = $event"
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"> :open="selectCourse" @update:model-value="(v) => {
<option value="" disabled>Select a course</option> field.onChange(v);
<option v-for="course in trainings" :key="course.id" :value="course.id"> selectCourse = false
{{ course.name }} }" class="w-full">
</option> <ComboboxAnchor class="w-full">
</select> <ComboboxInput @focus="selectCourse = true" placeholder="Search courses..."
class="w-full pl-3" :display-value="(id) => {
const c = trainings?.find(t => t.id === id)
return c ? c.name : '';
}" />
</ComboboxAnchor>
<ComboboxList class="w-full">
<ComboboxEmpty class="text-muted-foreground w-full">No results</ComboboxEmpty>
<ComboboxGroup>
<div class="max-h-80 overflow-y-scroll scrollbar-themed min-w-md">
<template v-for="course in trainings" :key="course.id">
<ComboboxItem :value="course.id"
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5 w-full">
{{ course.name }}
<ComboboxItemIndicator
class="absolute left-2 inline-flex items-center">
<Check class="h-4 w-4" />
</ComboboxItemIndicator>
</ComboboxItem>
</template>
</div>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<div class="h-4"> <div class="h-4">
<FieldError v-if="errors.length" :errors="errors" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
@@ -113,6 +171,7 @@ onMounted(async () => {
</VeeField> </VeeField>
</FieldGroup> </FieldGroup>
</div> </div>
<div class="w-[150px]"> <div class="w-[150px]">
<FieldGroup> <FieldGroup>
<VeeField v-slot="{ field, errors }" name="event_date"> <VeeField v-slot="{ field, errors }" name="event_date">
@@ -180,13 +239,38 @@ onMounted(async () => {
<!-- Member Select --> <!-- Member Select -->
<VeeField :name="`attendees[${index}].attendee_id`" v-slot="{ field: f, errors: e }"> <VeeField :name="`attendees[${index}].attendee_id`" v-slot="{ field: f, errors: e }">
<div> <div>
<select v-bind="f" <Combobox :model-value="f.value"
class="border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none"> @update:open="openMap['member-' + field.key] = $event"
<option value="">Select member...</option> :open="openMap['member-' + field.key]" @update:model-value="(v) => {
<option v-for="m in members" :key="m.member_id" :value="m.member_id"> f.onChange(v);
{{ m.member_name }} openMap['member-' + field.key] = false
</option> }" class="w-full">
</select> <ComboboxAnchor class="w-full">
<ComboboxInput
@focus="() => { openMap['member-' + field.key] = true; memberSearch = memberMap[f.value] }"
placeholder="Search members..." class="w-full pl-3"
:display-value="(id) => memberMap[id] || ''"
@input="memberSearch = $event.target.value" />
</ComboboxAnchor>
<ComboboxList class="w-full">
<ComboboxEmpty class="text-muted-foreground w-full">No results
</ComboboxEmpty>
<ComboboxGroup>
<div class="max-h-80 overflow-y-scroll scrollbar-themed min-w-3xs">
<template v-for="m in filteredMembers" :key="m.id">
<ComboboxItem :value="m.id"
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5 w-full">
{{ m.displayName || m.username }}
<ComboboxItemIndicator
class="absolute left-2 inline-flex items-center">
<Check class="h-4 w-4" />
</ComboboxItemIndicator>
</ComboboxItem>
</template>
</div>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<div class="h-4"> <div class="h-4">
<FieldError v-if="e.length" :errors="e" /> <FieldError v-if="e.length" :errors="e" />
</div> </div>
@@ -196,13 +280,42 @@ onMounted(async () => {
<!-- Role Select --> <!-- Role Select -->
<VeeField :name="`attendees[${index}].attendee_role_id`" v-slot="{ field: f, errors: e }"> <VeeField :name="`attendees[${index}].attendee_role_id`" v-slot="{ field: f, errors: e }">
<div> <div>
<select v-bind="f" <Combobox :model-value="f.value"
class="border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none"> @update:open="openMap['role-' + field.key] = $event"
<option value="">Select role...</option> :open="openMap['role-' + field.key]" @update:model-value="(v) => {
<option v-for="r in eventRoles" :key="r.id" :value="r.id"> f.onChange(v);
{{ r.name }} openMap['role-' + field.key] = false
</option> }" class="w-full">
</select> <ComboboxAnchor class="w-full">
<ComboboxInput @focus="openMap['role-' + field.key] = true"
placeholder="Search roles..." class="w-full pl-3" :display-value="(id) => {
const er = eventRoles?.find(t => t.id === id)
return er?.name;
}" />
</ComboboxAnchor>
<ComboboxList class="w-full">
<ComboboxEmpty class="text-muted-foreground w-full">No results
</ComboboxEmpty>
<ComboboxGroup>
<div class="max-h-80 overflow-y-scroll scrollbar-themed min-w-3xs">
<template v-for="r in eventRoles" :key="r.id">
<ComboboxItem :value="r.id"
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5 w-full">
<div class="flex justify-between w-full gap-8">
<p>{{ r.name }}</p>
<p class="text-muted-foreground">{{ r.description }}</p>
</div>
<ComboboxItemIndicator
class="absolute left-2 inline-flex items-center">
<Check class="h-4 w-4" />
</ComboboxItemIndicator>
</ComboboxItem>
</template>
</div>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<div class="h-4"> <div class="h-4">
<FieldError v-if="e.length" :errors="e" /> <FieldError v-if="e.length" :errors="e" />
</div> </div>

View File

@@ -1,7 +1,14 @@
<script setup> <script setup>
import { reactiveOmit } from "@vueuse/core"; import { getLocalTimeZone, today } from "@internationalized/date";
import { CalendarRoot, useForwardPropsEmits } from "reka-ui"; import { createReusableTemplate, reactiveOmit, useVModel } from "@vueuse/core";
import { CalendarRoot, useDateFormatter, useForwardPropsEmits } from "reka-ui";
import { createYear, createYearRange, toDate } from "reka-ui/date";
import { computed, toRaw } from "vue";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
NativeSelect,
NativeSelectOption,
} from '@/components/ui/native-select';
import { import {
CalendarCell, CalendarCell,
CalendarCellTrigger, CalendarCellTrigger,
@@ -38,34 +45,165 @@ const props = defineProps({
dir: { type: String, required: false }, dir: { type: String, required: false },
nextPage: { type: Function, required: false }, nextPage: { type: Function, required: false },
prevPage: { type: Function, required: false }, prevPage: { type: Function, required: false },
modelValue: { type: null, required: false }, modelValue: { type: null, required: false, default: undefined },
multiple: { type: Boolean, required: false }, multiple: { type: Boolean, required: false },
disableDaysOutsideCurrentView: { type: Boolean, required: false }, disableDaysOutsideCurrentView: { type: Boolean, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
layout: { type: null, required: false, default: undefined },
yearRange: { type: Array, required: false },
}); });
const emits = defineEmits(["update:modelValue", "update:placeholder"]); const emits = defineEmits(["update:modelValue", "update:placeholder"]);
const delegatedProps = reactiveOmit(props, "class"); const delegatedProps = reactiveOmit(props, "class", "layout", "placeholder");
const placeholder = useVModel(props, "placeholder", emits, {
passive: true,
defaultValue: props.defaultPlaceholder ?? today(getLocalTimeZone()),
});
const formatter = useDateFormatter(props.locale ?? "en");
const yearRange = computed(() => {
return (
props.yearRange ??
createYearRange({
start:
props?.minValue ??
(
toRaw(props.placeholder) ??
props.defaultPlaceholder ??
today(getLocalTimeZone())
).cycle("year", -100),
end:
props?.maxValue ??
(
toRaw(props.placeholder) ??
props.defaultPlaceholder ??
today(getLocalTimeZone())
).cycle("year", 10),
})
);
});
const [DefineMonthTemplate, ReuseMonthTemplate] = createReusableTemplate();
const [DefineYearTemplate, ReuseYearTemplate] = createReusableTemplate();
const forwarded = useForwardPropsEmits(delegatedProps, emits); const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DefineMonthTemplate v-slot="{ date }">
<div class="**:data-[slot=native-select-icon]:right-1">
<div class="relative">
<div
class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none"
>
{{ formatter.custom(toDate(date), { month: "short" }) }}
</div>
<NativeSelect
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
@change="
(e) => {
placeholder = placeholder.set({
month: Number(e?.target?.value),
});
}
"
>
<NativeSelectOption
v-for="month in createYear({ dateObj: date })"
:key="month.toString()"
:value="month.month"
:selected="date.month === month.month"
>
{{ formatter.custom(toDate(month), { month: "short" }) }}
</NativeSelectOption>
</NativeSelect>
</div>
</div>
</DefineMonthTemplate>
<DefineYearTemplate v-slot="{ date }">
<div class="**:data-[slot=native-select-icon]:right-1">
<div class="relative">
<div
class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none"
>
{{ formatter.custom(toDate(date), { year: "numeric" }) }}
</div>
<NativeSelect
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
@change="
(e) => {
placeholder = placeholder.set({
year: Number(e?.target?.value),
});
}
"
>
<NativeSelectOption
v-for="year in yearRange"
:key="year.toString()"
:value="year.year"
:selected="date.year === year.year"
>
{{ formatter.custom(toDate(year), { year: "numeric" }) }}
</NativeSelectOption>
</NativeSelect>
</div>
</div>
</DefineYearTemplate>
<CalendarRoot <CalendarRoot
v-slot="{ grid, weekDays }" v-slot="{ grid, weekDays, date }"
v-bind="forwarded"
v-model:placeholder="placeholder"
data-slot="calendar" data-slot="calendar"
:class="cn('p-3', props.class)" :class="cn('p-3', props.class)"
v-bind="forwarded"
> >
<CalendarHeader> <CalendarHeader class="pt-0">
<CalendarHeading /> <nav
class="flex items-center gap-1 absolute top-0 inset-x-0 justify-between"
>
<CalendarPrevButton>
<slot name="calendar-prev-icon" />
</CalendarPrevButton>
<CalendarNextButton>
<slot name="calendar-next-icon" />
</CalendarNextButton>
</nav>
<div class="flex items-center gap-1"> <slot
<CalendarPrevButton /> name="calendar-heading"
<CalendarNextButton /> :date="date"
</div> :month="ReuseMonthTemplate"
:year="ReuseYearTemplate"
>
<template v-if="layout === 'month-and-year'">
<div class="flex items-center justify-center gap-1">
<ReuseMonthTemplate :date="date" />
<ReuseYearTemplate :date="date" />
</div>
</template>
<template v-else-if="layout === 'month-only'">
<div class="flex items-center justify-center gap-1">
<ReuseMonthTemplate :date="date" />
{{ formatter.custom(toDate(date), { year: "numeric" }) }}
</div>
</template>
<template v-else-if="layout === 'year-only'">
<div class="flex items-center justify-center gap-1">
{{ formatter.custom(toDate(date), { month: "short" }) }}
<ReuseYearTemplate :date="date" />
</div>
</template>
<template v-else>
<CalendarHeading />
</template>
</slot>
</CalendarHeader> </CalendarHeader>
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0"> <div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">

View File

@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
const props = defineProps({ const props = defineProps({
date: { type: null, required: true }, date: { type: null, required: true },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });

View File

@@ -8,7 +8,7 @@ const props = defineProps({
day: { type: null, required: true }, day: { type: null, required: true },
month: { type: null, required: true }, month: { type: null, required: true },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false, default: "button" }, as: { type: null, required: false, default: "button" },
class: { type: null, required: false }, class: { type: null, required: false },
}); });

View File

@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });

View File

@@ -3,7 +3,7 @@ import { CalendarGridBody } from "reka-ui";
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false }, as: { type: null, required: false },
}); });
</script> </script>

View File

@@ -3,7 +3,7 @@ import { CalendarGridHead } from "reka-ui";
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });
</script> </script>

View File

@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });

View File

@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });
@@ -19,7 +19,7 @@ const forwardedProps = useForwardProps(delegatedProps);
data-slot="calendar-head-cell" data-slot="calendar-head-cell"
:class=" :class="
cn( cn(
'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]', 'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem]',
props.class, props.class,
) )
" "

View File

@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });
@@ -18,7 +18,10 @@ const forwardedProps = useForwardProps(delegatedProps);
<CalendarHeader <CalendarHeader
data-slot="calendar-header" data-slot="calendar-header"
:class=" :class="
cn('flex justify-center pt-1 relative items-center w-full', props.class) cn(
'flex justify-center pt-1 relative items-center w-full px-8',
props.class,
)
" "
v-bind="forwardedProps" v-bind="forwardedProps"
> >

View File

@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });

View File

@@ -8,7 +8,7 @@ import { buttonVariants } from '@/components/ui/button';
const props = defineProps({ const props = defineProps({
nextPage: { type: Function, required: false }, nextPage: { type: Function, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });
@@ -23,7 +23,6 @@ const forwardedProps = useForwardProps(delegatedProps);
:class=" :class="
cn( cn(
buttonVariants({ variant: 'outline' }), buttonVariants({ variant: 'outline' }),
'absolute right-1',
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100', 'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class, props.class,
) )

View File

@@ -8,7 +8,7 @@ import { buttonVariants } from '@/components/ui/button';
const props = defineProps({ const props = defineProps({
prevPage: { type: Function, required: false }, prevPage: { type: Function, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });
@@ -23,7 +23,6 @@ const forwardedProps = useForwardProps(delegatedProps);
:class=" :class="
cn( cn(
buttonVariants({ variant: 'outline' }), buttonVariants({ variant: 'outline' }),
'absolute left-1',
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100', 'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class, props.class,
) )

View File

@@ -0,0 +1,51 @@
<script setup>
import { reactiveOmit, useVModel } from "@vueuse/core";
import { ChevronDownIcon } from "lucide-vue-next";
import { cn } from "@/lib/utils";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
modelValue: { type: null, required: false },
class: { type: null, required: false },
});
const emit = defineEmits(["update:modelValue"]);
const modelValue = useVModel(props, "modelValue", emit, {
passive: true,
defaultValue: "",
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<div
class="group/native-select relative w-fit has-[select:disabled]:opacity-50"
data-slot="native-select-wrapper"
>
<select
v-bind="{ ...$attrs, ...delegatedProps }"
v-model="modelValue"
data-slot="native-select"
:class="
cn(
'border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 h-9 w-full min-w-0 appearance-none rounded-md border bg-transparent px-3 py-2 pr-9 text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)
"
>
<slot />
</select>
<ChevronDownIcon
class="text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<!-- @fallthroughAttributes true -->
<!-- @strictTemplates true -->
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<optgroup
data-slot="native-select-optgroup"
:class="cn('bg-popover text-popover-foreground', props.class)"
>
<slot />
</optgroup>
</template>

View File

@@ -0,0 +1,19 @@
<!-- @fallthroughAttributes true -->
<!-- @strictTemplates true -->
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<option
data-slot="native-select-option"
:class="cn('bg-popover text-popover-foreground', props.class)"
>
<slot />
</option>
</template>

View File

@@ -0,0 +1,3 @@
export { default as NativeSelect } from "./NativeSelect.vue";
export { default as NativeSelectOptGroup } from "./NativeSelectOptGroup.vue";
export { default as NativeSelectOption } from "./NativeSelectOption.vue";

View File

@@ -0,0 +1,33 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { PaginationRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
page: { type: Number, required: false },
defaultPage: { type: Number, required: false },
itemsPerPage: { type: Number, required: true },
total: { type: Number, required: false },
siblingCount: { type: Number, required: false },
disabled: { type: Boolean, required: false },
showEdges: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:page"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<PaginationRoot
v-slot="slotProps"
data-slot="pagination"
v-bind="forwarded"
:class="cn('mx-auto flex w-full justify-center', props.class)"
>
<slot v-bind="slotProps" />
</PaginationRoot>
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { PaginationList } 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>
<PaginationList
v-slot="slotProps"
data-slot="pagination-content"
v-bind="delegatedProps"
:class="cn('flex flex-row items-center gap-1', props.class)"
>
<slot v-bind="slotProps" />
</PaginationList>
</template>

View File

@@ -0,0 +1,27 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { MoreHorizontal } from "lucide-vue-next";
import { PaginationEllipsis } 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>
<PaginationEllipsis
data-slot="pagination-ellipsis"
v-bind="delegatedProps"
:class="cn('flex size-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="size-4" />
<span class="sr-only">More pages</span>
</slot>
</PaginationEllipsis>
</template>

View File

@@ -0,0 +1,36 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronLeftIcon } from "lucide-vue-next";
import { PaginationFirst, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
size: { type: null, required: false, default: "default" },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class", "size");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<PaginationFirst
data-slot="pagination-first"
:class="
cn(
buttonVariants({ variant: 'ghost', size }),
'gap-1 px-2.5 sm:pr-2.5',
props.class,
)
"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon />
<span class="hidden sm:block">First</span>
</slot>
</PaginationFirst>
</template>

View File

@@ -0,0 +1,35 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { PaginationListItem } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
value: { type: Number, required: true },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
size: { type: null, required: false, default: "icon" },
class: { type: null, required: false },
isActive: { type: Boolean, required: false },
});
const delegatedProps = reactiveOmit(props, "class", "size", "isActive");
</script>
<template>
<PaginationListItem
data-slot="pagination-item"
v-bind="delegatedProps"
:class="
cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
props.class,
)
"
>
<slot />
</PaginationListItem>
</template>

View File

@@ -0,0 +1,36 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronRightIcon } from "lucide-vue-next";
import { PaginationLast, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
size: { type: null, required: false, default: "default" },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class", "size");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<PaginationLast
data-slot="pagination-last"
:class="
cn(
buttonVariants({ variant: 'ghost', size }),
'gap-1 px-2.5 sm:pr-2.5',
props.class,
)
"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Last</span>
<ChevronRightIcon />
</slot>
</PaginationLast>
</template>

View File

@@ -0,0 +1,36 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronRightIcon } from "lucide-vue-next";
import { PaginationNext, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
size: { type: null, required: false, default: "default" },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class", "size");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<PaginationNext
data-slot="pagination-next"
:class="
cn(
buttonVariants({ variant: 'ghost', size }),
'gap-1 px-2.5 sm:pr-2.5',
props.class,
)
"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Next</span>
<ChevronRightIcon />
</slot>
</PaginationNext>
</template>

View File

@@ -0,0 +1,36 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronLeftIcon } from "lucide-vue-next";
import { PaginationPrev, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
size: { type: null, required: false, default: "default" },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class", "size");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<PaginationPrev
data-slot="pagination-previous"
:class="
cn(
buttonVariants({ variant: 'ghost', size }),
'gap-1 px-2.5 sm:pr-2.5',
props.class,
)
"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon />
<span class="hidden sm:block">Previous</span>
</slot>
</PaginationPrev>
</template>

View File

@@ -0,0 +1,8 @@
export { default as Pagination } from "./Pagination.vue";
export { default as PaginationContent } from "./PaginationContent.vue";
export { default as PaginationEllipsis } from "./PaginationEllipsis.vue";
export { default as PaginationFirst } from "./PaginationFirst.vue";
export { default as PaginationItem } from "./PaginationItem.vue";
export { default as PaginationLast } from "./PaginationLast.vue";
export { default as PaginationNext } from "./PaginationNext.vue";
export { default as PaginationPrevious } from "./PaginationPrevious.vue";

View File

@@ -0,0 +1,16 @@
<script setup>
import { Loader2Icon } from "lucide-vue-next";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<Loader2Icon
role="status"
aria-label="Loading"
:class="cn('size-4 animate-spin', props.class)"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Spinner } from "./Spinner.vue";

View File

@@ -6,11 +6,11 @@ export function useAuth() {
const userStore = useUserStore(); const userStore = useUserStore();
// Account status control // Account status control
const accountStatus = computed(() => userStore.state); const accountStatus = computed(() => userStore.user?.state);
// RBAC // RBAC
const roles = computed<string[]>(() => { const roles = computed<string[]>(() => {
return userStore.user?.roleData?.map((r: Role) => r.name) ?? []; return userStore.user?.roles?.map((r: Role) => r.name) ?? [];
}); });
function isDev() { function isDev() {

View File

@@ -2,14 +2,17 @@
import ApplicationChat from '@/components/application/ApplicationChat.vue'; import ApplicationChat from '@/components/application/ApplicationChat.vue';
import ApplicationForm from '@/components/application/ApplicationForm.vue'; import ApplicationForm from '@/components/application/ApplicationForm.vue';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { ApplicationData, approveApplication, denyApplication, loadApplication, postApplication, postChatMessage, ApplicationStatus } from '@/api/application'; import { approveApplication, denyApplication, loadApplication, postApplication, postChatMessage, getMyApplication, postAdminChatMessage } from '@/api/application';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import Button from '@/components/ui/button/Button.vue'; import Button from '@/components/ui/button/Button.vue';
import { CheckIcon, XIcon } from 'lucide-vue-next'; import { CheckIcon, XIcon } from 'lucide-vue-next';
import Unauthorized from './Unauthorized.vue';
import { ApplicationData, ApplicationFull, ApplicationStatus, CommentRow } from '@shared/types/application';
import Spinner from '@/components/ui/spinner/Spinner.vue';
const appData = ref<ApplicationData>(null); const appData = ref<ApplicationData>(null);
const appID = ref<number | null>(null); const appID = ref<number | null>(null);
const chatData = ref<object[]>([]) const chatData = ref<CommentRow[]>([])
const readOnly = ref<boolean>(false); const readOnly = ref<boolean>(false);
const newApp = ref<boolean>(null); const newApp = ref<boolean>(null);
const status = ref<ApplicationStatus>(null); const status = ref<ApplicationStatus>(null);
@@ -19,13 +22,12 @@ const loading = ref<boolean>(true);
const member_name = ref<string>(); const member_name = ref<string>();
const props = defineProps<{ const props = defineProps<{
mode?: "create" | "view-self" | "view-recruiter" mode?: "create" | "view-self" | "view-recruiter" | "view-self-id"
}>() }>()
const finalMode = ref<"create" | "view-self" | "view-recruiter">("create"); const finalMode = ref<"create" | "view-self" | "view-recruiter" | "view-self-id">("create");
async function loadByID(id: number | string) { function loadData(raw: ApplicationFull) {
const raw = await loadApplication(id);
const data = raw.application; const data = raw.application;
@@ -40,20 +42,20 @@ async function loadByID(id: number | string) {
readOnly.value = true; readOnly.value = true;
} }
const router = useRoute(); const route = useRoute();
const unauthorized = ref(false);
onMounted(async () => { onMounted(async () => {
//recruiter mode //recruiter mode
if (props.mode === 'view-recruiter') { if (props.mode === 'view-recruiter') {
finalMode.value = 'view-recruiter'; finalMode.value = 'view-recruiter';
await loadByID(Number(router.params.id)); loadData(await loadApplication(Number(route.params.id), true))
} }
//viewer mode //viewer mode
if (props.mode === 'view-self') { if (props.mode === 'view-self') {
finalMode.value = 'view-self'; finalMode.value = 'view-self';
await loadByID('me'); loadData(await loadApplication("me"))
} }
//creator mode //creator mode
@@ -64,40 +66,33 @@ onMounted(async () => {
newApp.value = true; newApp.value = true;
} }
if (props.mode === 'view-self-id') {
finalMode.value = 'view-self-id';
try {
let raw = await getMyApplication(Number(route.params.id))
loadData(raw);
unauthorized.value = false;
} catch (error) {
if (error.message === "Unauthorized") {
unauthorized.value = true;
} else {
console.error(error);
}
}
}
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));
} }
async function postCommentInternal(comment) {
chatData.value.push(await postAdminChatMessage(comment, appID.value));
}
const emit = defineEmits(['submit']); const emit = defineEmits(['submit']);
async function postApp(appData) { async function postApp(appData) {
@@ -107,68 +102,87 @@ async function postApp(appData) {
newApp.value = false; newApp.value = false;
emit('submit'); emit('submit');
} }
// TODO: Handle fail to post // TODO: Handle fail to post
} }
async function handleApprove(id) { async function handleApprove(id) {
console.log("hi"); try {
await approveApplication(id); await approveApplication(id);
loadData(await loadApplication(Number(route.params.id), true))
} catch (error) {
console.error(error);
}
} }
async function handleDeny(id) { async function handleDeny(id) {
await denyApplication(id); try {
await denyApplication(id);
loadData(await loadApplication(Number(route.params.id), true))
} catch (error) {
console.error(error);
}
} }
</script> </script>
<template> <template>
<div v-if="!loading" class="w-full h-20"> <div v-if="!loading" class="w-full">
<div v-if="!newApp" class="flex flex-row justify-between items-center py-2 mb-8"> <div v-if="unauthorized" class="flex justify-center w-full my-10">
<!-- Application header --> You do not have permission to view this application.
<div> </div>
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">{{ member_name }}</h3> <div v-else>
<p class="text-muted-foreground">Submitted: {{ submitDate.toLocaleString("en-US", { <div v-if="!newApp" class="flex flex-row justify-between items-center py-2 mb-8">
year: "numeric", <!-- Application header -->
month: "long", <div>
day: "numeric", <h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">{{ member_name }}</h3>
hour: "2-digit", <p class="text-muted-foreground">Submitted: {{ submitDate?.toLocaleString("en-US", {
minute: "2-digit"
}) }}</p>
</div>
<div>
<h3 class="text-right" :class="[
'font-semibold',
status === ApplicationStatus.Pending && 'text-yellow-500',
status === ApplicationStatus.Accepted && 'text-green-500',
status === ApplicationStatus.Denied && 'text-red-500'
]">{{ status }}</h3>
<p v-if="status != ApplicationStatus.Pending" class="text-muted-foreground">{{ status }}: {{
decisionDate.toLocaleString("en-US", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit" minute: "2-digit"
}) }}</p> }) }}</p>
<div class="mt-2" v-else-if="finalMode === 'view-recruiter'"> </div>
<Button variant="success" class="mr-2" :onclick="() => { handleApprove(appID) }"> <div>
<CheckIcon></CheckIcon> <h3 class="text-right" :class="[
</Button> 'font-semibold',
<Button variant="destructive" :onClick="() => { handleDeny(appID) }"> status === ApplicationStatus.Pending && 'text-yellow-500',
<XIcon></XIcon> status === ApplicationStatus.Accepted && 'text-green-500',
</Button> status === ApplicationStatus.Denied && 'text-red-500'
]">{{ status }}</h3>
<p v-if="status != ApplicationStatus.Pending" class="text-muted-foreground">{{ status }}: {{
decisionDate.toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
}) }}</p>
<div class="mt-2" v-else-if="finalMode === 'view-recruiter'">
<Button variant="success" class="mr-2" :onclick="() => { handleApprove(appID) }">
<CheckIcon></CheckIcon>
</Button>
<Button variant="destructive" :onClick="() => { handleDeny(appID) }">
<XIcon></XIcon>
</Button>
</div>
</div> </div>
</div> </div>
<div v-else class="flex flex-row justify-between items-center py-2 mb-8">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">Apply to join the 17th Rangers</h3>
</div>
<ApplicationForm :read-only="readOnly" :data="appData" @submit="(e) => { postApp(e) }" class="mb-7">
</ApplicationForm>
<div v-if="!newApp" class="pb-15">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight mb-4">Discussion</h3>
<ApplicationChat :messages="chatData" @post="postComment" @post-internal="postCommentInternal"
:admin-mode="finalMode === 'view-recruiter'">
</ApplicationChat>
</div>
</div> </div>
<div v-else class="flex flex-row justify-between items-center py-2 mb-8">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">Apply to join the 17th Rangers</h3>
</div>
<ApplicationForm :read-only="readOnly" :data="appData" @submit="(e) => { postApp(e) }" class="mb-7">
</ApplicationForm>
<div v-if="!newApp">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight mb-4">Discussion</h3>
<ApplicationChat :messages="chatData" @post="postComment"></ApplicationChat>
</div>
</div> </div>
<!-- TODO: Implement some kinda loading screen --> <!-- TODO: Implement some kinda loading screen -->
<div v-else class="flex items-center justify-center h-full">Loading</div> <div v-else class="flex items-center justify-center h-full">
<Spinner class="size-8"/>
</div>
</template> </template>

View File

@@ -217,7 +217,7 @@ onMounted(() => {
<aside v-if="panelOpen" <aside v-if="panelOpen"
class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col overflow-auto scrollbar-themed" class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col overflow-auto scrollbar-themed"
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }"> :style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
<ViewCalendarEvent ref="eventViewRef" @close="() => { router.push('/calendar'); }" <ViewCalendarEvent ref="eventViewRef" :key="currentEventID" @close="() => { router.push('/calendar'); }"
@reload="loadEvents()" @edit="(val) => { dialogRef.openDialog(null, 'edit', val) }"> @reload="loadEvents()" @edit="(val) => { dialogRef.openDialog(null, 'edit', val) }">
</ViewCalendarEvent> </ViewCalendarEvent>
</aside> </aside>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
</script>
<template>
<div>
Alo
<iframe src="https://docs.iceberg-gaming.com/" ></iframe>
</div>
</template>

View File

@@ -14,10 +14,13 @@ import { useUserStore } from '@/stores/user';
import { Check, Circle, Dot, Users, X } from 'lucide-vue-next' import { Check, Circle, Dot, Users, X } from 'lucide-vue-next'
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import Application from './Application.vue'; import Application from './Application.vue';
import { restartApplication } from '@/api/application';
function goToLogin() { function goToLogin() {
const redirectUrl = encodeURIComponent(window.location.origin + '/join') const redirectUrl = encodeURIComponent(window.location.origin + '/join')
window.location.href = `https://aj17thdevapi.nexuszone.net/login?redirect=${redirectUrl}`; //@ts-ignore
const addr = import.meta.env.VITE_APIHOST;
window.location.href = `${addr}/login?redirect=${redirectUrl}`;
} }
let userStore = useUserStore(); let userStore = useUserStore();
@@ -67,14 +70,25 @@ const currentStep = computed<number>(() => {
case "denied": case "denied":
return 5; return 5;
break; break;
case "retired":
return 5;
break;
} }
}) })
const finalPanel = ref<'app' | 'message'>('message'); const finalPanel = ref<'app' | 'message'>('message');
const reloadKey = ref(0);
async function restartApp() {
await restartApplication();
await userStore.loadUser();
reloadKey.value++;
}
</script> </script>
<template> <template>
<div class="flex flex-col items-center mt-10 w-full"> <div class="flex flex-col items-center mt-10 w-full" :key="reloadKey">
<!-- Stepper Container --> <!-- Stepper Container -->
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
@@ -171,9 +185,9 @@ const finalPanel = ref<'app' | 'message'>('message');
<li>When prompted, choose <em>“Yes”</em> to download all associated mods.</li> <li>When prompted, choose <em>“Yes”</em> to download all associated mods.</li>
</ul> </ul>
<p> <p>
<a href="https://www.guilded.gg/Iceberg-gaming/groups/v3j2vAP3/channels/6979335e-60f7-4ab9-9590-66df69367d1e/docs/2013948655" <a href="https://docs.iceberg-gaming.com/books/member-guides/page/new-member-setup-onboarding"
class="text-primary underline" target="_blank"> class="text-primary underline" target="_blank">
Click here for the full installation guide Click here for the full installation guide (Requires Sign-in)
</a> </a>
</p> </p>
<!-- CONTACT SECTION --> <!-- CONTACT SECTION -->
@@ -211,7 +225,7 @@ const finalPanel = ref<'app' | 'message'>('message');
our forums and introduce yourself. our forums and introduce yourself.
</p> </p>
<p> <p>
If you have any questions, feel free to reach out on TeamSpeak, Discord, or Guilded, If you have any questions, feel free to reach out on TeamSpeak or Discord
someone someone
will always be around to help. will always be around to help.
</p> </p>
@@ -219,8 +233,8 @@ const finalPanel = ref<'app' | 'message'>('message');
</div> </div>
<!-- Denied message --> <!-- Denied message -->
<div v-else-if="userStore.state === 'denied'"> <div v-else-if="userStore.state === 'denied'">
<div class="w-full max-w-2xl p-8"> <div class="w-full max-w-2xl flex flex-col gap-8">
<h1 class="text-3xl sm:text-4xl font-bold mb-4 text-left text-destructive"> <h1 class="text-3xl sm:text-4xl font-bold text-left">
Application Not Approved Application Not Approved
</h1> </h1>
<div class="space-y-4 text-muted-foreground text-left leading-relaxed"> <div class="space-y-4 text-muted-foreground text-left leading-relaxed">
@@ -246,6 +260,39 @@ const finalPanel = ref<'app' | 'message'>('message');
Team</span> Team</span>
</p> </p>
</div> </div>
<Button class="w-min" @click="restartApp">New Application</Button>
</div>
</div>
<div v-else-if="userStore.state === 'retired'">
<div class="w-full max-w-2xl flex flex-col gap-8">
<h1 class="text-3xl sm:text-4xl font-bold text-left">
You have retired from the 17th Ranger Battalion
</h1>
<div class="space-y-4 text-muted-foreground text-left leading-relaxed">
<p>
Thank you for your service and participation in the <strong>17th Ranger
Battalion</strong>.
Your time with us has been sincerely appreciated.
</p>
<p>
Should you ever wish to return, you are welcome to <strong>reach out to our
leadership
team</strong>
for guidance on the reinstatement process or to stay connected with the community.
</p>
<p>
We recognize that circumstances change, and you will always have a place to
reconnect with
us
should the opportunity arise in the future.
</p>
<p>
All the best,<br />
<span class="text-foreground font-medium">The 17th Ranger Battalion Leadership
Team</span>
</p>
</div>
<Button class="w-min" @click="restartApp">New Application</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import { getAllApplications, approveApplication, denyApplication, ApplicationStatus } from '@/api/application'; import { getAllApplications, approveApplication, denyApplication } from '@/api/application';
import { ApplicationStatus } from '@shared/types/application'
import { import {
Table, Table,
TableBody, TableBody,
@@ -10,10 +11,11 @@ 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, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, 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'; import Application from './Application.vue';
import MemberCard from '@/components/members/MemberCard.vue';
const appList = ref([]); const appList = ref([]);
const now = Date.now(); const now = Date.now();
@@ -50,36 +52,26 @@ function formatExact(iso) {
return isNaN(d) ? '' : exactFmt.format(d) return isNaN(d) ? '' : exactFmt.format(d)
} }
async function handleApprove(id) {
await approveApplication(id);
appList.value = await getAllApplications();
}
async function handleDeny(id) {
await denyApplication(id);
appList.value = await getAllApplications();
}
const router = useRouter(); const router = useRouter();
function openApplication(id) { function openApplication(id) {
if (!id) return;
router.push(`/administration/applications/${id}`) router.push(`/administration/applications/${id}`)
openPanel.value = true;
} }
function closeApplication() { function closeApplication() {
router.push('/administration/applications') router.push('/administration/applications')
openPanel.value = false;
} }
const route = useRoute(); const route = useRoute();
watch(() => route.params.id, (newId) => { watch(() => route.params.id, (newId) => {
if (newId === undefined) { if (newId === undefined) {
openPanel.value = false;
} }
}) })
const openPanel = ref(false); // const openPanel = ref(false);
const openPanel = computed(() => route.params.id !== undefined)
onMounted(async () => { onMounted(async () => {
appList.value = await getAllApplications(); appList.value = await getAllApplications();
@@ -95,39 +87,42 @@ onMounted(async () => {
<!-- application list --> <!-- application list -->
<div :class="openPanel == false ? 'w-full' : 'w-2/5'" class="pr-9"> <div :class="openPanel == false ? 'w-full' : 'w-2/5'" class="pr-9">
<h1 class="scroll-m-20 text-2xl font-semibold tracking-tight">Manage Applications</h1> <h1 class="scroll-m-20 text-2xl font-semibold tracking-tight">Manage Applications</h1>
<Table> <div class="max-h-[80vh] overflow-hidden">
<TableHeader> <Table class="w-full">
<TableRow> <TableHeader>
<TableHead>User</TableHead> <TableRow>
<TableHead>Date Submitted</TableHead> <TableHead>User</TableHead>
<TableHead class="text-right">Status</TableHead> <TableHead class="text-right">Date Submitted</TableHead>
</TableRow> <TableHead class="text-right">Status</TableHead>
</TableHeader> </TableRow>
<TableBody class="overflow-y-auto scrollbar-themed"> </TableHeader>
<TableRow v-for="app in appList" :key="app.id" class="cursor-pointer" </Table>
:onClick="() => { openApplication(app.id) }">
<TableCell class="font-medium">{{ app.member_name }}</TableCell> <!-- Scrollable body container -->
<TableCell :title="formatExact(app.submitted_at)"> <div class="overflow-y-auto max-h-[70vh] scrollbar-themed">
{{ formatAgo(app.submitted_at) }} <Table class="w-full">
</TableCell> <TableBody>
<TableCell v-if="app.app_status === ApplicationStatus.Pending" <TableRow v-for="app in appList" :key="app.id" class="cursor-pointer"
class="inline-flex items-end gap-2"> @click="openApplication(app.id)">
<Button variant="success" @click.stop="() => { handleApprove(app.id) }"> <TableCell class="font-medium">
<CheckIcon></CheckIcon> <MemberCard :memberId="app.member_id"></MemberCard>
</Button> </TableCell>
<Button variant="destructive" @click.stop="() => { handleDeny(app.id) }"> <TableCell class="text-right" :title="formatExact(app.submitted_at)">
<XIcon></XIcon> {{ formatAgo(app.submitted_at) }}
</Button> </TableCell>
</TableCell>
<TableCell class="text-right font-semibold" :class="[ <TableCell class="text-right font-semibold" :class="[
, app.app_status === ApplicationStatus.Pending && 'text-yellow-500',
app.app_status === ApplicationStatus.Pending && 'text-yellow-500', app.app_status === ApplicationStatus.Accepted && 'text-green-500',
app.app_status === ApplicationStatus.Accepted && 'text-green-500', app.app_status === ApplicationStatus.Denied && 'text-destructive'
app.app_status === ApplicationStatus.Denied && 'text-destructive' ]">
]">{{ app.app_status }}</TableCell> {{ app.app_status }}
</TableRow> </TableCell>
</TableBody> </TableRow>
</Table> </TableBody>
</Table>
</div>
</div>
</div> </div>
<div v-if="openPanel" class="pl-9 border-l w-3/5" :key="$route.params.id"> <div v-if="openPanel" class="pl-9 border-l w-3/5" :key="$route.params.id">
<div class="mb-5 flex justify-between"> <div class="mb-5 flex justify-between">
@@ -143,32 +138,4 @@ onMounted(async () => {
</div> </div>
</template> </template>
<style scoped> <style scoped></style>
/* 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

@@ -17,27 +17,26 @@ const showLOADialog = ref(false);
</script> </script>
<template> <template>
<Dialog v-model:open="showLOADialog" v-on:update:open="showLOADialog = false"> <div>
<DialogContent> <Dialog v-model:open="showLOADialog" v-on:update:open="showLOADialog = false">
<DialogHeader> <DialogContent class="sm:max-w-fit">
<DialogTitle>Post LOA</DialogTitle> <DialogHeader>
<DialogDescription> <DialogTitle>Post LOA</DialogTitle>
Post an LOA on behalf of a member. <DialogDescription>
</DialogDescription> Post an LOA on behalf of a member.
</DialogHeader> </DialogDescription>
<LoaForm :admin-mode="true" class="my-5 w-full"></LoaForm> </DialogHeader>
<!-- <DialogFooter> <LoaForm :admin-mode="true" class="my-3"></LoaForm>
<Button variant="secondary" @click="showLOADialog = false">Cancel</Button> </DialogContent>
<Button>Apply</Button> </Dialog>
</DialogFooter> --> <div class="max-w-5xl mx-auto pt-10">
</DialogContent> <div class="flex justify-between mb-4">
</Dialog> <h1 class="scroll-m-20 text-2xl font-semibold tracking-tight">LOA Log</h1>
<div>
<div class="max-w-5xl mx-auto pt-10"> <Button @click="showLOADialog = true">Post LOA</Button>
<div class="flex justify-end mb-4"> </div>
<Button @click="showLOADialog = true">Post LOA</Button> </div>
<LoaList :admin-mode="true"></LoaList>
</div> </div>
<h1>LOA Log</h1>
<LoaList></LoaList>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,148 @@
<script setup>
import { loadMyApplications } from '@/api/application';
import { ApplicationStatus } from '@shared/types/application';
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import Button from '@/components/ui/button/Button.vue';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { CheckIcon, XIcon } from 'lucide-vue-next';
import Application from './Application.vue';
const appList = ref([]);
const now = Date.now();
// relative time formatter (uses user locale)
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
// exact date/time for tooltip
const exactFmt = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium', timeStyle: 'short', timeZone: 'America/Toronto'
})
function formatAgo(iso) {
const d = new Date(iso)
if (isNaN(d)) return ''
let diff = (d.getTime() - now) / 1000 // seconds relative to page load
const divisions = [
{ amount: 60, name: 'second' },
{ amount: 60, name: 'minute' },
{ amount: 24, name: 'hour' },
{ amount: 7, name: 'day' },
{ amount: 4.34524, name: 'week' }, // avg weeks per month
{ amount: 12, name: 'month' },
{ amount: Infinity, name: 'year' },
]
for (const div of divisions) {
if (Math.abs(diff) < div.amount) {
return rtf.format(Math.round(diff), div.name)
}
diff /= div.amount
}
}
function formatExact(iso) {
const d = new Date(iso)
return isNaN(d) ? '' : exactFmt.format(d)
}
const router = useRouter();
function openApplication(id) {
router.push(`/applications/${id}`)
openPanel.value = true;
}
const route = useRoute();
watch(() => route.params.id, (newId) => {
if (newId === undefined) {
openPanel.value = false;
}
})
const openPanel = ref(false);
onMounted(async () => {
appList.value = await loadMyApplications();
//preload application
if (route.params.id != undefined) {
openApplication(route.params.id)
} else {
}
})
</script>
<template>
<div class="px-20 mx-auto max-w-[100rem] w-full flex mt-5 h-52 min-h-0 overflow-hidden">
<!-- application list -->
<div :class="openPanel == false ? 'w-full' : 'w-2/5'" class="pr-9">
<h1 class="scroll-m-20 text-2xl font-semibold tracking-tight mb-5">My Applications</h1>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date Submitted</TableHead>
<TableHead class="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody class="overflow-y-auto scrollbar-themed">
<TableRow v-for="app in appList" :key="app.id" class="cursor-pointer"
:onClick="() => { openApplication(app.id) }">
<TableCell :title="formatExact(app.submitted_at)">
{{ formatAgo(app.submitted_at) }}
</TableCell>
<TableCell class="text-right font-semibold" :class="[
,
app.app_status === ApplicationStatus.Pending && 'text-yellow-500',
app.app_status === ApplicationStatus.Accepted && 'text-green-500',
app.app_status === ApplicationStatus.Denied && 'text-destructive'
]">{{ app.app_status }}</TableCell>
</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>
</div>
<div class="overflow-y-auto max-h-[80vh] h-full mt-5 scrollbar-themed">
<Application :mode="'view-self-id'"></Application>
</div>
</div>
</div>
</template>
<style scoped>
/* Firefox */
.scrollbar-themed {
scrollbar-width: thin;
scrollbar-color: #555 #1f1f1f;
padding-right: 6px;
}
/* Chrome, Edge, Safari */
.scrollbar-themed::-webkit-scrollbar {
width: 10px;
/* slightly wider to allow padding look */
}
.scrollbar-themed::-webkit-scrollbar-track {
background: #1f1f1f;
margin-left: 6px;
/* ❗ adds space between content + scrollbar */
}
.scrollbar-themed::-webkit-scrollbar-thumb {
background: #555;
border-radius: 9999px;
}
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { memberSettings } from '@shared/types/member'
import { getMemberSettings, setMemberSettings } from "@/api/member";
import Spinner from "@/components/ui/spinner/Spinner.vue";
import { useMemberDirectory } from "@/stores/memberDirectory";
import { useUserStore } from "@/stores/user";
const saving = ref(false);
const loading = ref(true);
const showLoading = ref(false);
const form = ref<memberSettings>();
const memberDictionary = useMemberDirectory()
const userStore = useUserStore()
function saveSettings() {
saving.value = true;
setTimeout(async () => {
// Replace with your API save call
setMemberSettings(form.value);
saving.value = false;
console.log(userStore.user.id)
memberDictionary.invalidateMember(userStore.user.id)
}, 800);
}
onMounted(async () => {
// Start a brief timer before showing the spinner
const timer = setTimeout(() => {
showLoading.value = true;
}, 200); // 150250ms is ideal
form.value = await getMemberSettings();
clearTimeout(timer);
loading.value = false;
showLoading.value = false; // ensure spinner hides if it was shown
});
</script>
<template>
<div class="mx-auto max-w-3xl w-full py-10 px-6 space-y-10">
<!-- Page Header -->
<div>
<h1 class="scroll-m-20 text-2xl font-semibold tracking-tight">Profile Settings</h1>
<p class="text-muted-foreground mt-1">
Manage your account information and display preferences.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Account Info</CardTitle>
<CardDescription>Your identity across the platform.</CardDescription>
</CardHeader>
<Transition name="fade" mode="out-in">
<CardContent class="space-y-6 min-h-40" v-if="!loading">
<!-- Display Name -->
<div class="grid gap-2">
<Label for="displayName">Display Name</Label>
<Input id="displayName" v-model="form.displayName" placeholder="Your display name" />
</div>
</CardContent>
<CardContent v-else class="min-h-40 space-y-6 flex items-center">
<Spinner v-if="showLoading" class="size-7 flex mx-auto -my-10"></Spinner>
</CardContent>
</Transition>
<CardFooter class="flex justify-end">
<Button @click="saveSettings" :disabled="saving">
{{ saving ? "Saving..." : "Save Changes" }}
</Button>
</CardFooter>
</Card>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.05s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,20 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import LoaForm from '@/components/loa/loaForm.vue'; import LoaForm from '@/components/loa/loaForm.vue';
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user';
import { Member } from '@/api/member'; import { Member } from '@shared/types/member';
import LoaList from '@/components/loa/loaList.vue';
import { ref } from 'vue';
const userStore = useUserStore(); const userStore = useUserStore();
const user = userStore.user;
const memberFull: Member = { const mode = ref<'submit' | 'view'>('submit')
member_id: user.id,
member_name: user.name,
rank: null,
rank_date: null,
status: null,
status_date: null,
};
</script> </script>
<template> <template>
<LoaForm class="m-10" :member="memberFull"></LoaForm> <div class="max-w-5xl mx-auto flex w-full flex-col mt-4 mb-10">
<div class="mb-8">
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">Leave of Absence</p>
<div class="pt-3">
<div class="flex w-min *:px-10 pt-2 border-b *:w-full *:text-center *:pb-1 *:cursor-pointer">
<label :class="mode === 'submit' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="mode = 'submit'">Submit</label>
<label :class="mode === 'view' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="mode = 'view'">History</label>
</div>
</div>
</div>
<LoaForm v-if="mode === 'submit'" :member="userStore.user.member"></LoaForm>
<LoaList v-if="mode === 'view'" :admin-mode="false"></LoaList>
</div>
</template> </template>

View File

@@ -21,6 +21,17 @@ import SelectValue from '@/components/ui/select/SelectValue.vue';
import SelectContent from '@/components/ui/select/SelectContent.vue'; import SelectContent from '@/components/ui/select/SelectContent.vue';
import SelectItem from '@/components/ui/select/SelectItem.vue'; import SelectItem from '@/components/ui/select/SelectItem.vue';
import Input from '@/components/ui/input/Input.vue'; import Input from '@/components/ui/input/Input.vue';
import MemberCard from '@/components/members/MemberCard.vue';
import Spinner from '@/components/ui/spinner/Spinner.vue';
import { pagination } from '@shared/types/pagination';
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
enum sidePanelState { view, create, closed }; enum sidePanelState { view, create, closed };
@@ -41,6 +52,7 @@ watch(() => route.params.id, async (newID) => {
focusedTrainingReport.value = null; focusedTrainingReport.value = null;
return; return;
} }
TRLoaded.value = false;
viewTrainingReport(Number(route.params.id)); viewTrainingReport(Number(route.params.id));
}) })
@@ -59,6 +71,7 @@ const focusedTrainingTrainers = computed<CourseAttendee[] | null>(() => {
}) })
async function viewTrainingReport(id: number) { async function viewTrainingReport(id: number) {
focusedTrainingReport.value = await getTrainingReport(id); focusedTrainingReport.value = await getTrainingReport(id);
TRLoaded.value = true;
} }
async function closeTrainingReport() { async function closeTrainingReport() {
@@ -83,15 +96,36 @@ watch(() => sortMode.value, async (newSortMode) => {
}) })
async function loadTrainingReports() { async function loadTrainingReports() {
trainingReports.value = await getTrainingReports(sortMode.value, searchString.value); let data = await getTrainingReports(sortMode.value, searchString.value, pageNum.value, pageSize.value);
trainingReports.value = data.data;
pageData.value = data.pagination;
} }
onMounted(async () => { onMounted(async () => {
loadTrainingReports(); await loadTrainingReports();
if (route.params.id) if (route.params.id)
viewTrainingReport(Number(route.params.id)) viewTrainingReport(Number(route.params.id))
loaded.value = true; loaded.value = true;
}) })
const TRLoaded = ref(false);
const pageNum = ref<number>(1);
const pageData = ref<pagination>();
const pageSize = ref<number>(15)
const pageSizeOptions = [10, 15, 30]
function setPageSize(size: number) {
pageSize.value = size
pageNum.value = 1;
loadTrainingReports();
}
function setPage(pagenum: number) {
pageNum.value = pagenum;
loadTrainingReports();
}
</script> </script>
<template> <template>
@@ -112,7 +146,7 @@ onMounted(async () => {
<div class="flex flex-row gap-5"> <div class="flex flex-row gap-5">
<div> <div>
<label class="text-muted-foreground">Search</label> <label class="text-muted-foreground">Search</label>
<Input v-model="searchString"></Input> <Input v-model="searchString" placeholder="Search..."></Input>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<label class="text-muted-foreground">Sort By</label> <label class="text-muted-foreground">Sort By</label>
@@ -152,31 +186,68 @@ onMounted(async () => {
<TableCell class="font-medium">{{ report.course_name.length > 30 ? report.course_shortname : <TableCell class="font-medium">{{ report.course_name.length > 30 ? report.course_shortname :
report.course_name }}</TableCell> report.course_name }}</TableCell>
<TableCell>{{ report.date.split('T')[0] }}</TableCell> <TableCell>{{ report.date.split('T')[0] }}</TableCell>
<TableCell class="text-right">{{ report.created_by_name === null ? "Unknown User" : <TableCell class="text-right">
<MemberCard v-if="report.created_by_name" :member-id="report.created_by"></MemberCard>
<span v-else>Unknown User</span>
</TableCell>
<!-- <TableCell class="text-right">{{ report.created_by_name === null ? "Unknown User" :
report.created_by_name report.created_by_name
}}</TableCell> }}</TableCell> -->
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<div class="mt-5 flex justify-between" :class="sidePanel !== sidePanelState.closed ? 'flex-col items-center' : 'items-center justify-between'"
>
<div></div>
<Pagination v-slot="{ page }" :items-per-page="pageData?.pageSize || 10" :total="pageData?.total || 10"
:default-page="2" :page="pageNum" @update:page="setPage">
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template v-for="(item, index) in items" :key="index">
<PaginationItem v-if="item.type === 'page'" :value="item.value"
:is-active="item.value === page">
{{ item.value }}
</PaginationItem>
</template>
<PaginationEllipsis :index="4" />
<PaginationNext />
</PaginationContent>
</Pagination>
<div class="flex items-center gap-3 text-sm" :class="sidePanel !== sidePanelState.closed ? 'mt-3' : ''" >
<p class="text-muted-foreground text-nowrap">Per page:</p>
<button v-for="size in pageSizeOptions" :key="size" @click="setPageSize(size)"
class="px-2 py-1 rounded transition-colors" :class="{
'bg-muted font-semibold': pageSize === size,
'hover:bg-muted/50': pageSize !== size
}">
{{ size }}
</button>
</div>
</div>
</div> </div>
<!-- view training report section --> <!-- view training report section -->
<div v-if="focusedTrainingReport != null && sidePanel == sidePanelState.view" class="pl-9 my-3 border-l w-3/5"> <div v-if="focusedTrainingReport != null && sidePanel == sidePanelState.view" class="pl-9 my-3 border-l w-3/5">
<div class="flex justify-between"> <div class="flex justify-between items-center">
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">Training Report Details</p> <p class="scroll-m-20 text-2xl font-semibold tracking-tight">Training Report Details</p>
<button @click="closeTrainingReport" class="cursor-pointer"> <Button @click="closeTrainingReport" class="cursor-pointer" variant="ghost" size="icon">
<X></X> <X class="size-6"></X>
</button> </Button>
</div> </div>
<div class="max-h-[70vh] overflow-auto scrollbar-themed my-5"> <div v-if="TRLoaded" class="max-h-[70vh] overflow-auto scrollbar-themed my-5">
<div class="flex flex-col mb-5 border rounded-lg bg-muted/70 p-2 py-3 px-4"> <div class="flex flex-col mb-5 border rounded-lg bg-muted/70 p-2 py-3 px-4">
<p class="scroll-m-20 text-xl font-semibold tracking-tight">{{ focusedTrainingReport.course_name }} <p class="scroll-m-20 text-xl font-semibold tracking-tight">{{ focusedTrainingReport.course_name }}
</p> </p>
<div class="flex gap-10"> <div class="flex gap-10 items-center">
<p class="text-muted-foreground">{{ focusedTrainingReport.event_date.split('T')[0] }}</p> <p class="text-muted-foreground">{{ focusedTrainingReport.event_date.split('T')[0] }}</p>
<p class="">Created by {{ focusedTrainingReport.created_by_name === null ? "Unknown User" : <p class="flex gap-2 items-center">Created by:
<MemberCard v-if="focusedTrainingReport.created_by"
:member-id="focusedTrainingReport.created_by" />
<p v-else>{{ focusedTrainingReport.created_by_name === null ? "Unknown User" :
focusedTrainingReport.created_by_name focusedTrainingReport.created_by_name
}} }}</p>
</p> </p>
</div> </div>
</div> </div>
@@ -191,7 +262,11 @@ onMounted(async () => {
</div> </div>
<div v-for="person in focusedTrainingTrainers" <div v-for="person in focusedTrainingTrainers"
class="grid grid-cols-4 py-2 items-center border-b last:border-none"> class="grid grid-cols-4 py-2 items-center border-b last:border-none">
<p>{{ person.attendee_name }}</p> <div>
<MemberCard v-if="person.attendee_id" :member-id="person.attendee_id"
class="justify-self-start"></MemberCard>
<p v-else>{{ person.attendee_name }}</p>
</div>
<p class="">{{ person.role.name }}</p> <p class="">{{ person.role.name }}</p>
<p class="col-span-2 text-right px-2" <p class="col-span-2 text-right px-2"
:class="person.remarks == '' ? 'text-muted-foreground' : ''"> :class="person.remarks == '' ? 'text-muted-foreground' : ''">
@@ -213,7 +288,11 @@ onMounted(async () => {
</div> </div>
<div v-for="person in focusedTrainingTrainees" <div v-for="person in focusedTrainingTrainees"
class="grid grid-cols-5 py-2 items-center border-b last:border-none"> class="grid grid-cols-5 py-2 items-center border-b last:border-none">
<p>{{ person.attendee_name }}</p> <div>
<MemberCard v-if="person.attendee_id" :member-id="person.attendee_id"
class="justify-self-start"></MemberCard>
<p v-else>{{ person.attendee_name }}</p>
</div>
<Checkbox :disabled="!focusedTrainingReport.course.hasQual" <Checkbox :disabled="!focusedTrainingReport.course.hasQual"
:model-value="person.passed_bookwork" class="pointer-events-none ml-5"> :model-value="person.passed_bookwork" class="pointer-events-none ml-5">
</Checkbox> </Checkbox>
@@ -242,7 +321,11 @@ onMounted(async () => {
</div> </div>
<div v-for="person in focusedNoShows" <div v-for="person in focusedNoShows"
class="grid grid-cols-5 py-2 items-center border-b last:border-none"> class="grid grid-cols-5 py-2 items-center border-b last:border-none">
<p>{{ person.attendee_name }}</p> <div>
<MemberCard v-if="person.attendee_id" :member-id="person.attendee_id"
class="justify-self-start"></MemberCard>
<p v-else>{{ person.attendee_name }}</p>
</div>
<!-- <Checkbox :default-value="person.passed_bookwork ? true : false" class="pointer-events-none"> <!-- <Checkbox :default-value="person.passed_bookwork ? true : false" class="pointer-events-none">
</Checkbox> </Checkbox>
<Checkbox :default-value="person.passed_qual ? true : false" class="pointer-events-none"> <Checkbox :default-value="person.passed_qual ? true : false" class="pointer-events-none">
@@ -264,15 +347,18 @@ onMounted(async () => {
</div> </div>
</div> </div>
</div> </div>
<div v-else class="flex w-full items-center justify-center h-[80%]">
<Spinner class="size-8"></Spinner>
</div>
</div> </div>
<div v-if="sidePanel == sidePanelState.create" class="pl-7 border-l w-3/5 max-w-5xl"> <div v-if="sidePanel == sidePanelState.create" class="pl-7 border-l w-3/5 max-w-5xl">
<div class="flex justify-between my-3"> <div class="flex justify-between items-center my-3">
<div class="flex pl-2 gap-5"> <div class="flex pl-2 gap-5">
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">New Training Report</p> <p class="scroll-m-20 text-2xl font-semibold tracking-tight">New Training Report</p>
</div> </div>
<button @click="closeTrainingReport" class="cursor-pointer"> <Button @click="closeTrainingReport" class="cursor-pointer" variant="ghost" size="icon">
<X></X> <X class="size-6"></X>
</button> </Button>
</div> </div>
<div class="overflow-y-auto max-h-[70vh] mt-5 scrollbar-themed"> <div class="overflow-y-auto max-h-[70vh] mt-5 scrollbar-themed">
<TrainingReportForm class="w-full pl-2" <TrainingReportForm class="w-full pl-2"

View File

@@ -65,6 +65,7 @@ const searchedMembers = computed(() => {
Member Member
</TableHead> </TableHead>
<TableHead>Rank</TableHead> <TableHead>Rank</TableHead>
<TableHead>Unit</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -75,6 +76,7 @@ const searchedMembers = computed(() => {
{{ member.member_name }} {{ member.member_name }}
</TableCell> </TableCell>
<TableCell>{{ member.rank }}</TableCell> <TableCell>{{ member.rank }}</TableCell>
<TableCell>{{ member.unit }}</TableCell>
<TableCell>{{ member.status }}</TableCell> <TableCell>{{ member.status }}</TableCell>
<TableCell><Badge v-if="member.on_loa">On LOA</Badge></TableCell> <TableCell><Badge v-if="member.on_loa">On LOA</Badge></TableCell>
<TableCell @click.stop="" class="text-right"> <TableCell @click.stop="" class="text-right">

View File

@@ -6,19 +6,25 @@ const router = createRouter({
routes: [ routes: [
// PUBLIC // PUBLIC
{ path: '/join', component: () => import('@/pages/Join.vue') }, { path: '/join', component: () => import('@/pages/Join.vue') },
{ path: '/applications', component: () => import('@/pages/MyApplications.vue'), meta: { requiresAuth: true } },
{ path: '/applications/:id', component: () => import('@/pages/MyApplications.vue'), meta: { requiresAuth: true } },
// AUTH REQUIRED // AUTH REQUIRED
{ path: '/apply', component: () => import('@/pages/Application.vue'), meta: { requiresAuth: true } },
{ path: '/', component: () => import('@/pages/Homepage.vue') }, { path: '/', component: () => import('@/pages/Homepage.vue') },
// MEMBER ROUTES // MEMBER ROUTES
{ path: '/members', component: () => import('@/pages/memberList.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/members', component: () => import('@/pages/memberList.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/transfer', component: () => import('@/pages/Transfer.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/transfer', component: () => import('@/pages/Transfer.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/profile', component: () => import('@/pages/MyProfile.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/calendar', component: () => import('@/pages/Calendar.vue'), meta: { }, },
{ path: '/calendar/event/:id', component: () => import('@/pages/Calendar.vue'), meta: { }, },
{ path: '/calendar', component: () => import('@/pages/Calendar.vue') },
{ path: '/calendar/event/:id', component: () => import('@/pages/Calendar.vue') },
// disabled in favor of linking
// { path: '/documents', component: () => import('@/pages/Documentation.vue'), meta: { requiresAuth: true, memberOnly: true }, },
{ path: '/trainingReport', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/trainingReport', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/trainingReport/new', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/trainingReport/new', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/trainingReport/:id', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/trainingReport/:id', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
@@ -26,7 +32,7 @@ const router = createRouter({
// ADMIN / STAFF ROUTES // ADMIN / STAFF ROUTES
{ {
path: '/administration', path: '/administration',
meta: { requiresAuth: true, memberOnly: true, roles: ['staff', 'admin'] }, meta: { requiresAuth: true, memberOnly: true, roles: ['17th Administrator', '17th HQ', '17th Command'] },
children: [ children: [
{ path: 'applications', component: () => import('@/pages/ManageApplications.vue') }, { path: 'applications', component: () => import('@/pages/ManageApplications.vue') },
{ path: 'applications/:id', component: () => import('@/pages/ManageApplications.vue') }, { path: 'applications/:id', component: () => import('@/pages/ManageApplications.vue') },

View File

@@ -0,0 +1,140 @@
import { defineStore } from "pinia"
import type { MemberLight, Member } from "@shared/types/member"
import { getLightMembers, getFullMembers } from "@/api/member"
import { reactive, ref } from "vue"
import { resolve } from "path"
import { rejects } from "assert"
export const useMemberDirectory = defineStore('memberDirectory', () => {
const light = reactive<Record<number, MemberLight>>({});
const full = reactive<Record<number, Member>>({})
function getLight(id: number): Promise<MemberLight> {
if (light[id]) return Promise.resolve(light[id]);
if (!lightWaiters.has(id)) {
pendingLight.add(id);
lightWaiters.set(id, []);
}
scheduleBatch();
return new Promise<MemberLight>((resolve, reject) => {
lightWaiters.get(id)!.push({ resolve, reject })
})
}
function getFull(id: number): Promise<Member> {
if (full[id]) return Promise.resolve(full[id])
if (!fullWaiters.has(id)) {
pendingFull.add(id)
fullWaiters.set(id, [])
}
scheduleBatch()
return new Promise<Member>((resolve, reject) => {
fullWaiters.get(id)!.push({ resolve, reject })
})
}
function invalidateMember(id: number) {
delete light[id]
delete full[id]
}
//batching system
const pendingLight = new Set<number>()
const pendingFull = new Set<number>()
// promises
const lightWaiters = new Map<number, Array<{ resolve: (m: MemberLight) => void; reject: (e: any) => void }>>()
const fullWaiters = new Map<number, Array<{ resolve: (m: Member) => void; reject: (e: any) => void }>>()
let batchTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleBatch() {
if (batchTimer) return
batchTimer = setTimeout(async () => {
batchTimer = null;
//Batch light
if (pendingLight.size > 0) {
const ids = Array.from(pendingLight);
pendingLight.clear();
try {
const res = await getLightMembers(ids);
for (const m of res) {
light[m.id] = m;
const waiters = lightWaiters.get(m.id);
if (waiters) {
for (const w of waiters) w.resolve(m)
lightWaiters.delete(m.id)
}
}
for (const id of ids) {
if (!light[id]) {
const waiters = lightWaiters.get(id);
if (waiters) {
for (const w of waiters) w.reject("Not found");
lightWaiters.delete(id);
}
}
}
} catch (error) {
for (const id of ids) {
const waiters = lightWaiters.get(id);
if (waiters) {
for (const w of waiters) w.reject(error);
lightWaiters.delete(id);
}
}
}
}
//batch full
if (pendingFull.size > 0) {
const ids = Array.from(pendingFull);
pendingFull.clear();
try {
const res = await getFullMembers(ids);
for (const m of res) {
full[m.member_id] = m;
const waiters = fullWaiters.get(m.member_id);
if (waiters) {
for (const w of waiters) w.resolve(m)
fullWaiters.delete(m.member_id);
}
}
for (const id of ids) {
if (!light[id]) {
const waiters = fullWaiters.get(id);
if (waiters) {
for (const w of waiters) w.reject("Not found");
fullWaiters.delete(id);
}
}
}
} catch (error) {
for (const id of ids) {
const waiters = fullWaiters.get(id);
if (waiters) {
for (const w of waiters) w.reject(error);
fullWaiters.delete(id);
}
}
}
}
})
}
return { light, full, getLight, getFull, invalidateMember }
})

View File

@@ -1,12 +1,18 @@
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { myData } from '@shared/types/member'
const POLL_INTERVAL = 10_000
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const user = ref(null) const user = ref<myData>(null)
const roles = computed(() => new Set(user.value?.roleData?.map(r => r.name) ?? [])); const roles = computed(() => new Set(user.value?.roles?.map(r => r.name) ?? []));
const loaded = ref(false); const loaded = ref(false);
const state = computed<string | undefined>(() => user.value?.state || undefined); const state = computed<string | undefined>(() => user.value?.state || undefined);
const isLoggedIn = computed(() => user.value !== null) const isLoggedIn = computed(() => user.value !== null)
const displayName = computed(() => user.value?.member.displayName || user.value?.member.member_name)
async function loadUser() { async function loadUser() {
//@ts-ignore //@ts-ignore
@@ -16,7 +22,6 @@ export const useUserStore = defineStore('user', () => {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
console.log(data);
user.value = data; user.value = data;
} }
@@ -33,5 +38,56 @@ export const useUserStore = defineStore('user', () => {
return requiredRoles.some(r => roles.value.has(r)) return requiredRoles.some(r => roles.value.has(r))
} }
return { user, isLoggedIn, roles, loadUser, loaded, hasAnyRole, hasRole, state } const route = useRoute();
const router = useRouter();
watch(user, (newUser) => {
if (!newUser) return
const currentRoute = route.meta
// Member-only route
if (currentRoute.memberOnly && state.value !== 'member') {
router.replace('/unauthorized')
return
}
// Role-based route
if (currentRoute.roles && !hasRole('Dev') && !hasAnyRole(currentRoute.roles as string[])) {
return '/unauthorized'
}
},
{ deep: true } // deep watch ensures nested changes trigger
)
//polling system
let pollTimeout: number | null = null
let polling = false;
let lastVersion: string | null = null
async function poll() {
// Only poll if tab is visible
if (document.hidden) {
polling = false;
return
}
await loadUser();
scheduleNext()
}
function scheduleNext() {
polling = true;
pollTimeout = window.setTimeout(poll, POLL_INTERVAL)
}
poll() //start polling
document.addEventListener('visibilitychange', () => {
if (!document.hidden && polling === false) {
poll()
}
})
return { user, displayName, isLoggedIn, roles, loadUser, loaded, hasAnyRole, hasRole, state }
}) })