222 Commits

Author SHA1 Message Date
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
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
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
50b9924627 deployment versioning
Some checks failed
Continuous Integration / Update Development (push) Successful in 1m58s
Continuous Deployment / Update Deployment (push) Failing after 2m12s
port development versioning to deployment, but using tags instead of short sha
2025-12-10 11:50:07 -06:00
d44d490905 version parsing fix
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m13s
attempt parse fix because i forgot this isn't running on windows
2025-12-10 11:43:40 -06:00
114857cceb debug workflow
Some checks failed
Continuous Integration / Update Development (push) Failing after 1m49s
is sed really not included?
2025-12-10 11:35:58 -06:00
0245cb38f4 enable development app versioning
Some checks failed
Continuous Integration / Update Development (push) Failing after 2m16s
parse git commit short sha and use as app version in development
2025-12-10 11:30:13 -06:00
4e443cf46d Fixed glitchtip enable/disable to behave as expected and added support for ENV versioning
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m27s
2025-12-10 00:18:09 -05:00
be53fce4a5 Merge pull request 'Calendar-Improvements' (#57) from Calendar-Improvements into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m23s
Reviewed-on: #57
2025-12-09 21:21:24 -06:00
2ac66877eb Merge branch 'main' into Calendar-Improvements 2025-12-09 21:21:03 -06:00
760965de19 Add .gitea/workflows/ci-deploy.yaml
Some checks failed
Continuous Integration / Update Development (push) Failing after 1m28s
create CI script based on CD script to run on every push to main and deploy on development site
2025-12-09 21:11:03 -06:00
37f204137b Update .gitea/workflows/cd-deploy.yaml
fix run, apparently on push triggers are not cumulatively conditional
2025-12-09 21:06:45 -06:00
58431891ba Update ecosystem.config.js
All checks were successful
Continuous Deployment / Update Deployment (push) Successful in 2m15s
allow automatic naming of started process based on environment
2025-12-09 20:55:41 -06:00
a95bf3318d Update .gitea/workflows/cd-deploy.yaml
All checks were successful
Continuous Deployment / Update Deployment (push) Successful in 2m5s
update the CD script to only run on tags signifying a usable release
2025-12-09 20:53:33 -06:00
6d544225d9 Update .gitea/workflows/cd-deploy.yaml
All checks were successful
Continuous Deployment / Update Deployment (push) Successful in 3m20s
fix syntax
2025-12-09 20:31:38 -06:00
ec854c6800 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m17s
update node version to 20 from built in 18
2025-12-09 20:26:13 -06:00
8089ee4268 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m48s
stupidly work out permissions error due to sudo not copying environment variables properly
2025-12-09 20:22:08 -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
468fd30514 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m27s
attempt to preserve existing environment to fix node issues
2025-12-09 14:55:40 -06:00
1f52a2c4f7 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m27s
forcefully override npm path for execution
2025-12-09 14:48:19 -06:00
dccdaddd20 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m28s
another deployment attempt
2025-12-09 14:45:24 -06:00
1a80bfc543 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m33s
continue to debug workflow
2025-12-09 14:37:16 -06:00
2185ffc746 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m32s
debug workflow action
2025-12-09 14:27:42 -06: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
c04a2b06cb Fixed glitchtip disabled inversion on API
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m22s
2025-12-08 14:51:02 -05:00
8c1b132f8d Merge branch 'Calendar-Improvements' of https://gitea.iceberg-gaming.com/17th-Ranger-Battalion-ORG/milsim-site-v4 into Calendar-Improvements
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m17s
2025-12-05 16:47:27 -05:00
4e69b419f0 Merge branch 'main' into Calendar-Improvements
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m18s
2025-12-05 15:43:49 -06:00
a233b15ad4 Switched clock icon to something more readable at that size 2025-12-05 16:34:00 -05:00
94b5bfcff7 Added support for public read only calendar #53
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m18s
2025-12-05 16:28:42 -05:00
09d20fd18f prevented header wiggle when editable value changes
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m18s
2025-12-05 16:16:51 -05:00
89280dd3a7 Added (hidden) attendees summary
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m18s
2025-12-05 16:12:25 -05:00
575455a0fc overhauled attendance visualization to be more useful for planning
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m22s
2025-12-05 16:04:32 -05:00
ccb132b9b0 Improvements to event details UI to address #41
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m30s
2025-12-05 12:53:32 -05:00
98138f51f4 Fixed glitchtip disabled state inverted (whoops)
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m12s
2025-12-04 23:42:28 -05:00
e550f862bc event details area initial redesign 2025-12-04 23:41:55 -05:00
6d6789c4a6 Disabled attendance buttons if event end time is past current time to address #55
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m13s
2025-12-04 22:55:57 -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
5a7b3ba2ab fixed application form sizing
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m12s
2025-12-03 19:23:50 -05:00
2de6b18135 Fixed scrolling on unauthroized page 2025-12-03 19:16:10 -05:00
aedcbd9492 Sticky'd navbar
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m11s
2025-12-03 19:14:47 -05:00
f985e0234c Merge pull request 'Onboarding-Reworko' (#52) from Onboarding-Reworko into main
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m11s
Reviewed-on: #52
2025-12-03 16:58:17 -06:00
66ad8df0c1 Merge branch 'main' into Onboarding-Reworko
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m10s
2025-12-03 16:58:03 -06:00
9bc3098d58 Merge pull request 'Navbar-Rework' (#50) from Navbar-Rework into main
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m11s
Reviewed-on: #50
2025-12-03 16:57:56 -06:00
5aef2f6b73 Merge branch 'main' into Navbar-Rework
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m11s
2025-12-03 16:57:26 -06:00
1faae36abc Merge pull request 'Added more strict form validation rules with user feedback' (#51) from Training-Report-Validation into main
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m11s
Reviewed-on: #51
2025-12-03 16:57:14 -06:00
cf98bba0cd fixed loading placeholder location 2025-12-03 17:56:59 -05:00
d31cb50d71 Added navigation to application stuff under the profile menu 2025-12-03 17:53:31 -05:00
4e6745553b added application view to final stage of onboarding 2025-12-03 17:44:41 -05:00
faf183a23d fixed scrolling issues 2025-12-03 17:01:52 -05:00
3449dcec5c minor UX refinements 2025-12-03 16:05:47 -05:00
12d538dafc vastly modified acceptance/denial display 2025-12-03 15:36:49 -05:00
b79e78c2a6 overhauled recruiter tools 2025-12-03 15:20:37 -05:00
b8f18c060e Mega recruitment pipeline overhaul 2025-12-03 13:37:03 -05:00
41cdd0b74f Added more strict form validation rules with user feedback
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m55s
2025-12-03 00:55:53 -05:00
c537ef9b60 fixed incorrect account status evaluation 2025-12-02 20:31:01 -05:00
26fd323f43 Guest navigation state 2025-12-02 00:09:51 -05:00
9fe18f6b1a Admin navigation permissions 2025-12-01 23:57:26 -05:00
9ac885da56 Changed LOA form label 2025-12-01 19:59:33 -05:00
35cb149202 navbar V2 hell yeah 2025-12-01 18:42:06 -05:00
b40f37c959 cerate deployment configuration
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 2m16s
add continuous deployment yaml to execute on every push to main to build and make live
2025-11-30 18:18:36 -06:00
5a31d86969 Split navbar from main app 2025-11-30 18:55:41 -05:00
b193785f88 Add ecosystem.config.js
create PM2 ecosystem file so the api node application can be added to bare metal system launcher for autostart and restart on code deploy
2025-11-30 16:35:32 -06:00
f9fabef97e Merge pull request 'Glitchtip-Integration' (#39) from Glitchtip-Integration into main
Reviewed-on: #39
2025-11-29 12:25:00 -06:00
d1bfa035f9 fixed misconfigured sentry 2025-11-29 13:25:49 -05:00
1aac9d8c1a Added sentry and dev environment warning baner 2025-11-29 13:18:24 -05:00
4952ed55ae Merge pull request 'Fixed first time login error' (#38) from First-Time-Login-Fix into main
Reviewed-on: #38
2025-11-28 18:02:33 -06:00
d0252ded44 Fixed first time login error 2025-11-28 19:01:07 -05:00
f2aa28b1a4 Merge pull request 'Update ui/src/components/trainingReport/trainingReportForm.vue' (#36) from training-report-cbox-reset into main
Reviewed-on: #36
2025-11-28 16:00:26 -06:00
c935a9950c sorted possible training options by name 2025-11-28 17:00:07 -05:00
e5806e275f cleaned up date formats a bit 2025-11-28 16:47:42 -05:00
d24a01db8c Integrated new time handling system 2025-11-28 15:31:35 -05:00
f499e33fe1 Merge remote-tracking branch 'Origin/main' into training-report-cbox-reset 2025-11-28 15:15:51 -05:00
bfcd7d4c7a fixed header unbalance 2025-11-28 15:14:56 -05:00
8321b67baf Modified header to address pass/fail confusions 2025-11-28 15:14:23 -05:00
40e097fc71 disabled extra logging 2025-11-28 14:47:18 -05:00
104946b2d1 Fixed checkbox reset not updating visually 2025-11-28 14:41:06 -05:00
54475b529e Merge pull request '20-calendar-system' (#37) from 20-calendar-system into main
Reviewed-on: #37
2025-11-28 00:06:10 -06:00
9ca6b55b03 Merge remote-tracking branch 'Origin/main' into 20-calendar-system 2025-11-28 01:05:22 -05:00
99e66763b0 tweaked dialog scrollbar 2025-11-28 01:01:14 -05:00
521dc70f86 adjusted form error message spacing 2025-11-28 00:54:28 -05:00
3a34a35edb Fixed event description rendering handling whitespace and newline 2025-11-28 00:47:44 -05:00
2a9dc51a5d overhauled color selection system 2025-11-28 00:41:50 -05:00
6d53d3e254 tweaked edit mode titles 2025-11-27 23:12:35 -05:00
f82a750cee Implemented update event systems 2025-11-27 23:10:20 -05:00
9896a9289a improved reactivity of event creation 2025-11-27 20:14:11 -05:00
2f2071bd32 Fixed unknown event creator issue 2025-11-27 20:02:23 -05:00
0ba42e6f78 finalized event cancel logic 2025-11-27 19:53:31 -05:00
33fcb16427 beat calendar styling into submission to support multi day events 2025-11-27 15:19:05 -05:00
0b3a95cdc0 implemented cancelled event visualization 2025-11-27 13:40:58 -05:00
941004f913 Finished first pass of event creation system 2025-11-27 13:15:41 -05:00
e14ad7ad44 fixed an import issue 2025-11-27 13:15:23 -05:00
81716d4a4f hooked up create event 2025-11-27 13:08:33 -05:00
4dc121c018 made events open instantly when navigating to a given link 2025-11-26 09:33:43 -05:00
3f9df22a5d added API build command 2025-11-26 09:26:35 -05:00
de84b0d849 broke up the mega monolith that is the calendar file 2025-11-25 23:26:44 -05:00
2d294b7549 Fixed attendance button outlines 2025-11-25 22:45:05 -05:00
f4fae1f84c Modified Checkbox Updates on Course re-select 2025-11-25 21:23:13 -06:00
560a82cc09 Added live update after attendance set 2025-11-25 22:04:33 -05:00
1a714289ee Adapted calendar to support event links 2025-11-25 21:41:01 -05:00
145479adfe added my current attendance state to buttons 2025-11-25 20:30:51 -05:00
ca4f6a811f Integrated attendance system 2025-11-25 13:11:08 -05:00
121dd44a78 Merge pull request 'Fixed hardcoded nonsense in api config' (#31) from API-config-fix into main
Reviewed-on: #31
2025-11-24 22:47:05 -06:00
0d1788500b Update ui/src/components/trainingReport/trainingReportForm.vue
Added a Watcher Code to clear checkboxes when a different training report is picked.
2025-11-24 22:35:47 -06:00
0d9e7c3e3b Fixed hardcoded nonsense in api config 2025-11-23 23:39:10 -05:00
0a718d36c2 split event view into seperate component 2025-11-23 23:12:58 -05:00
658980d9fe fixed text handling on excessively long titles 2025-11-23 18:43:38 -05:00
531371d059 Hooked up calendar viewing to API, still needs a lot more polish 2025-11-23 17:00:47 -05:00
b8bf809c14 Merge pull request '26-login-route' (#28) from 26-login-route into main
Reviewed-on: #28
2025-11-22 17:08:38 -06:00
31d602dbab fixed bad login redirect 2025-11-22 18:02:39 -05:00
836f19e4c7 fixed duplicate URL thing 2025-11-22 18:02:25 -05:00
eabd2da07e added env example comment 2025-11-22 15:45:24 -05:00
0e4725d33c corrected old env names and fixed logout redirect 2025-11-22 15:41:22 -05:00
ac3792c72b Merge pull request 'Training-Report' (#27) from Training-Report into main
Reviewed-on: #27
2025-11-22 14:20:50 -06:00
2e3960a93a Merge branch 'main' into Training-Report 2025-11-22 13:08:30 -05:00
712941458a added searching and sorting system 2025-11-22 11:32:51 -05:00
9f2948ac18 fixed scrolling behaviour 2025-11-21 12:15:22 -05:00
7528a20568 fixed checkbox states getting stuck when switching between views 2025-11-21 12:07:54 -05:00
c72e849b24 refactored to make training reports linkable 2025-11-21 11:57:23 -05:00
856f34f0fa added support for short course names in the case of super long names (looking at you RSLC) 2025-11-21 11:25:50 -05:00
1dcffef2c2 reenabled submitting form 2025-11-21 11:18:26 -05:00
2a327f0d41 fixed inverted checkbox states 2025-11-21 11:16:54 -05:00
a4cd982d3e major style overhaul to report view 2025-11-21 10:51:02 -05:00
938d489f7d minor alignment tweak 2025-11-20 19:40:36 -05:00
9eb815cde5 Major style pass to the form 2025-11-20 19:39:29 -05:00
03a8eee409 added proper error messages 2025-11-20 19:27:09 -05:00
a5461359b7 Implemented tooltips for disabled inputs 2025-11-20 17:39:46 -05:00
9322affce5 adjusted grid styling 2025-11-20 15:11:14 -05:00
91b915fbcf fixed schema validation to support multi checkbox 2025-11-20 14:55:43 -05:00
d9e4c1d6ff added support for optional checkboxes 2025-11-20 14:27:34 -05:00
23ebbe7a85 update report list on submit 2025-11-20 11:08:57 -05:00
aaec72af7e Made all created by human readable 2025-11-20 10:06:01 -05:00
105b28d9a4 Integrated "created by" system 2025-11-20 09:22:59 -05:00
7a31c77c7e applied scroll behaviour to the form too 2025-11-20 09:07:31 -05:00
a075162502 tweaked training report scroll behaviour 2025-11-20 09:06:16 -05:00
aad87096b5 added redirect to completed form on form submission 2025-11-20 00:38:50 -05:00
3560268640 corrected trainer role display 2025-11-20 00:37:56 -05:00
9d14b767a1 FINALLY FIXED THE FUCKING CHECKBOX OH MY GOD 2025-11-19 22:39:08 -05:00
93440eab95 fixed reserved env name 2025-11-19 21:37:16 -05:00
2d28582962 Revert "Updated client env references"
This reverts commit ca5066249f.
2025-11-19 21:32:12 -05:00
ca5066249f Updated client env references 2025-11-19 21:21:49 -05:00
a1a5654f63 fixed leftover hardcoded API logic 2025-11-19 21:13:33 -05:00
0a67d0c82b Merge pull request 'hardcode-fix' (#25) from hardcode-fix into main
Reviewed-on: #25

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

View File

@@ -0,0 +1,95 @@
name: Continuous Deployment
on:
push:
tags:
- '*'
jobs:
Deploy:
name: Update Deployment
runs-on: ubuntu-latest
container:
volumes:
- /var/www/html/milsim-site-v4:/var/www/html/milsim-site-v4:z
steps:
- name: Setup Local Environment
run: |
groupadd -g 989 nginx || true
useradd nginx -u 990 -g nginx -m || true
- name: Update Node Environment
uses: actions/setup-node@v6
with:
node-version: 20.19
- name: Verify Local Environment
run: |
which npm
npm -v
which node
node -v
which sed
sed --version
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: 'main'
- name: Token Copy
run: |
cd /var/www/html/milsim-site-v4
cp /workspace/17th-Ranger-Battalion-ORG/milsim-site-v4/.git/config .git/config
chown nginx:nginx .git/config
- name: Update Application Code
run: |
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 pull origin main
sudo -u nginx git pull origin main
new_version=`git log -1 --format=%H`
echo "Sucessfully updated to: $new_version
- name: Update Shared Dependencies and Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/shared
npm install
chown -R nginx:nginx .
- name: Update UI Dependencies and Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/ui
npm install
chown -R nginx:nginx .
- name: Update API Dependencies and Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/api
npm install
chown -R nginx:nginx .
- name: Build UI / Update Version / Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/ui
npm run build
version=`git describe --abbrev=0 --tags`
sed -i "s/VITE_APPLICATION_VERSION=.*/VITE_APPLICATION_VERSION=$version/" .env
chown -R nginx:nginx .
- name: Build API / Update Version / Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/api
npm run build
version=`git describe --abbrev=0 --tags`
sed -i "s/APPLICATION_VERSION=.*/APPLICATION_VERSION=$version/" .env
chown -R nginx:nginx .
- name: Reset File Permissions
run: |
sudo chown -R nginx:nginx /var/www/html/milsim-site-v4
sudo chmod -R u+w /var/www/html/milsim-site-v4

View File

@@ -0,0 +1,89 @@
name: Continuous Integration
on:
push:
branches:
- main
jobs:
Deploy:
name: Update Development
runs-on: ubuntu-latest
container:
volumes:
- /var/www/html/milsim-site-v4-dev:/var/www/html/milsim-site-v4:z
steps:
- name: Setup Local Environment
run: |
groupadd -g 989 nginx || true
useradd nginx -u 990 -g nginx -m || true
- name: Update Node Environment
uses: actions/setup-node@v6
with:
node-version: 20.19
- name: Verify Local Environment
run: |
which npm
npm -v
which node
node -v
which sed
sed --version
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: 'main'
- name: Token Copy
run: |
cd /var/www/html/milsim-site-v4
cp /workspace/17th-Ranger-Battalion-ORG/milsim-site-v4/.git/config .git/config
chown nginx:nginx .git/config
- name: Update Application Code
run: |
cd /var/www/html/milsim-site-v4
sudo -u nginx git reset --hard
sudo -u nginx git pull origin main
- name: Update Shared Dependencies and Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/shared
npm install
chown -R nginx:nginx .
- name: Update UI Dependencies and Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/ui
npm install
chown -R nginx:nginx .
- name: Update API Dependencies and Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/api
npm install
chown -R nginx:nginx .
- name: Build UI / Update Version / Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/ui
npm run build
version=`git rev-parse --short=10 HEAD`
sed -i "s/VITE_APPLICATION_VERSION=.*/VITE_APPLICATION_VERSION=$version/" .env
chown -R nginx:nginx .
- name: Build API / Update Version / Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/api
npm run build
version=`git rev-parse --short=10 HEAD`
sed -i "s/APPLICATION_VERSION=.*/APPLICATION_VERSION=$version/" .env
chown -R nginx:nginx .
- name: Reset File Permissions
run: |
sudo chown -R nginx:nginx /var/www/html/milsim-site-v4
sudo chmod -R u+w /var/www/html/milsim-site-v4

33
api/.env.example Normal file
View File

@@ -0,0 +1,33 @@
# DATABASE SETTINGS
DB_HOST=
DB_PORT=
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=
# AUTH SETTINGS
AUTH_DOMAIN=
AUTH_ISSUER=
AUTH_CLIENT_ID=
AUTH_CLIENT_SECRET=
AUTH_REDIRECT_URI=
AUTH_REVOCATION_URI=
AUTH_END_SESSION_URI=
# AUTH_MODE=mock #uncomment this to bypass authentik
# SERVER SETTINGS
SERVER_PORT=3000
CLIENT_URL= # This is whatever URL the client web app is served on
CLIENT_DOMAIN= #whatever.com
APPLICATION_VERSION= # Should match release tag
APPLICATION_ENVIRONMENT= # dev / prod
CONFIG_ID= # configures
# Glitchtip
GLITCHTIP_DSN=
DISABLE_GLITCHTIP= # true/false
# Bookstack
DOC_HOST= # https://bookstack.whatever.com/
DOC_TOKEN_SECRET=
DOC_TOKEN_ID=

1443
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -8,7 +8,7 @@ const app = express()
app.use(morgan('dev')) app.use(morgan('dev'))
app.use(cors({ app.use(cors({
origin: ['https://aj17thdev.nexuszone.net', 'http://localhost:5173'], // your SPA origins origin: [process.env.CLIENT_URL], // your SPA origins
credentials: true credentials: true
})); }));
@@ -18,6 +18,19 @@ app.set('trust proxy', 1);
const port = process.env.SERVER_PORT; const port = process.env.SERVER_PORT;
//glitchtip setup
const sentry = require('@sentry/node');
if (process.env.DISABLE_GLITCHTIP === "true") {
console.log("Glitchtip disabled")
} else {
let dsn = process.env.GLITCHTIP_DSN;
let release = process.env.APPLICATION_VERSION;
let environment = process.env.APPLICATION_ENVIRONMENT;
console.log(release, environment)
sentry.init({ dsn: dsn, release: release, environment: environment });
console.log("Glitchtip initialized");
}
//session setup //session setup
const path = require('path') const path = require('path')
const session = require('express-session') const session = require('express-session')
@@ -32,7 +45,7 @@ app.use(session({
cookie: { cookie: {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
domain: 'nexuszone.net' domain: process.env.CLIENT_DOMAIN
} }
})); }));
app.use(passport.authenticate('session')); app.use(passport.authenticate('session'));
@@ -45,7 +58,10 @@ const loaHandler = require('./routes/loa')
const { status, memberStatus } = require('./routes/statuses') const { status, memberStatus } = require('./routes/statuses')
const authRouter = require('./routes/auth') const authRouter = require('./routes/auth')
const { roles, memberRoles } = require('./routes/roles'); const { roles, memberRoles } = require('./routes/roles');
const { courseRouter, eventRouter } = require('./routes/course');
const { calendarRouter } = require('./routes/calendar')
const morgan = require('morgan'); const morgan = require('morgan');
const { env } = require('process');
app.use('/application', applicationsRouter); app.use('/application', applicationsRouter);
app.use('/ranks', ranks); app.use('/ranks', ranks);
@@ -56,6 +72,9 @@ app.use('/status', status)
app.use('/memberStatus', memberStatus) app.use('/memberStatus', memberStatus)
app.use('/roles', roles) app.use('/roles', roles)
app.use('/memberRoles', memberRoles) app.use('/memberRoles', memberRoles)
app.use('/course', courseRouter)
app.use('/courseEvent', eventRouter)
app.use('/calendar', calendarRouter)
app.use('/', authRouter) app.use('/', authRouter)
app.get('/ping', (req, res) => { app.get('/ping', (req, res) => {

View File

@@ -2,11 +2,32 @@ 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';
//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('/', async (req, res) => {
@@ -16,13 +37,13 @@ router.post('/', async (req, res) => {
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' });
} }
}); });
@@ -37,24 +58,57 @@ router.get('/all', async (req, res) => {
} }
}); });
router.get('/me', async (req, res) => { router.get('/meList', async (req, res) => {
let userID = req.user.id; let userID = req.user.id;
console.log("application/me") try {
let application = await getAllMemberApplications(userID);
let app = getMemberApplication(userID); return res.status(200).json(application);
console.log(app); } catch (error) {
console.error('Failed to load applications: \n', error);
return res.status(500).json(error);
}
})
router.get('/me', async (req, res) => {
let userID = req.user.id;
try {
let application = await getMemberApplication(userID);
if (application === undefined)
res.sendStatus(204);
const comments: CommentRow[] = await getApplicationComments(application.id);
const output: ApplicationFull = {
application,
comments,
}
return res.status(200).json(output);
} catch (error) {
console.error('Failed to load application:', error);
return res.status(500).json(error);
}
}) })
// GET /application/:id // GET /application/:id
router.get('/:id', async (req, res) => { router.get('/me/:id', 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);
const output: ApplicationFull = { const output: ApplicationFull = {
@@ -69,30 +123,64 @@ router.get('/:id', async (req, res) => {
} }
}); });
// GET /application/:id
router.get('/:id', async (req: Request, res: Response) => {
let appID = Number(req.params.id);
let asAdmin = !!req.query.admin || false;
let user = req.user.id;
//TODO: Replace this with bigger authorization system eventually
if (asAdmin) {
let allowed = (await getUserRoles(user)).some((role) =>
role.name.toLowerCase() === 'dev' ||
role.name.toLowerCase() === 'recruiter' ||
role.name.toLowerCase() === 'administrator')
console.log(allowed)
if (!allowed) {
return res.sendStatus(403)
}
}
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', 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); const result = await approveApplication(appID);
console.log("START");
console.log(app, result);
//guard against failures //guard against failures
if (result.affectedRows != 1) { if (result.affectedRows != 1) {
throw new Error("Something went wrong approving the application"); 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); // let nextRank = await getRankByName('Recruit')
//assign user to "pending basic" // await insertMemberRank(app.member_id, nextRank.id);
await assignUserToStatus(app.member_id, 1); // //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);
@@ -104,26 +192,11 @@ router.post('/approve/:id', async (req, res) => {
router.post('/deny/:id', async (req, res) => { router.post('/deny/:id', async (req, res) => {
const appID = req.params.id; const appID = req.params.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);
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' });
@@ -131,10 +204,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', 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,
@@ -146,7 +221,7 @@ VALUES(?, ?, ?);`
try { try {
const conn = await pool.getConnection(); const conn = await pool.getConnection();
const result = await conn.query(sql, [appID, user, data]) 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();
@@ -171,4 +246,61 @@ VALUES(?, ?, ?);`
} }
}); });
// POST /application/:id/comment
router.post('/:id/adminComment', 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 {
const 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' });
}
});
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' });
}
})
module.exports = router; module.exports = router;

View File

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

View File

@@ -1,4 +1,6 @@
import { getEventAttendance, getEventDetails, getShortEventsInRange } from "../services/calendarService"; import { Request, Response } from "express";
import { createEvent, getEventAttendance, getEventDetails, getShortEventsInRange, setAttendanceStatus, setEventCancelled, updateEvent } from "../services/calendarService";
import { CalendarAttendance, CalendarEvent } from "@app/shared/types/calendar";
const express = require('express'); const express = require('express');
const r = express.Router(); const r = express.Router();
@@ -9,42 +11,108 @@ function addMonths(date: Date, months: number): Date {
return d return d
} }
//get calendar events paged //get calendar events paged, requires a query string with from= and to= as mariadb ISO strings
r.get('/', async (req, res) => { r.get('/', async (req, res) => {
const viewDate: Date = req.body.date; try {
//generate date range const fromDate: string = req.query.from;
const backDate: Date = addMonths(viewDate, -1); const toDate: string = req.query.to;
const frontDate: Date = addMonths(viewDate, 2);
const events = getShortEventsInRange(backDate, frontDate); if (fromDate === undefined || toDate === undefined) {
res.status(400).send("Missing required query parameters 'from' and 'to'");
return;
}
res.status(200).json(events); const events = await getShortEventsInRange(fromDate, toDate);
res.status(200).json(events);
} catch (error) {
console.error('Error fetching calendar events:', error);
res.status(500).send('Error fetching calendar events');
}
}); });
r.get('/upcoming', async (req, res) => { r.get('/upcoming', async (req, res) => {
res.sendStatus(501); res.sendStatus(501);
}) })
//get event details r.post('/:id/cancel', async (req: Request, res: Response) => {
r.get('/:id', async (req, res) => {
try { try {
const eventID: number = req.params.id; const eventID = Number(req.params.id);
setEventCancelled(eventID, true);
res.sendStatus(200);
} catch (error) {
console.error('Error setting cancel status:', error);
res.status(500).send('Error setting cancel status');
}
})
r.post('/:id/uncancel', async (req: Request, res: Response) => {
try {
const eventID = Number(req.params.id);
setEventCancelled(eventID, false);
res.sendStatus(200);
} catch (error) {
console.error('Error setting cancel status:', error);
res.status(500).send('Error setting cancel status');
}
})
let details = getEventDetails(eventID);
let attendance = await getEventAttendance(eventID);
let out = { ...details, attendance } r.post('/:id/attendance', async (req: Request, res: Response) => {
console.log(out); try {
res.status(200).json(out); let member = req.user.id;
let event = Number(req.params.id);
let state = req.query.state as CalendarAttendance;
setAttendanceStatus(member, event, state);
res.sendStatus(200);
} catch (error) {
console.error('Failed to set attendance:', error);
res.status(500).json(error);
}
})
//get event details
r.get('/:id', async (req: Request, res: Response) => {
try {
const eventID: number = Number(req.params.id);
let details: CalendarEvent = await getEventDetails(eventID);
details.eventSignups = await getEventAttendance(eventID);
res.status(200).json(details);
} catch (err) { } catch (err) {
console.error('Insert failed:', err); console.error('Insert failed:', err);
res.status(500).json(err); res.status(500).json(err);
} }
}) })
//post a new calendar event
r.post('/', async (req, res) => {
//post a new calendar event
r.post('/', async (req: Request, res: Response) => {
try {
const member = req.user.id;
let event: CalendarEvent = req.body;
event.creator_id = member;
event.start = new Date(event.start);
event.end = new Date(event.end);
createEvent(event);
res.sendStatus(200);
} catch (error) {
console.error('Failed to create event:', error);
res.status(500).json(error);
}
}) })
module.exports.calendar = r; r.put('/', async (req: Request, res: Response) => {
try {
let event: CalendarEvent = req.body;
event.start = new Date(event.start);
event.end = new Date(event.end);
console.log(event);
updateEvent(event);
res.sendStatus(200);
} catch (error) {
console.error('Failed to update event:', error);
res.status(500).json(error);
}
})
module.exports.calendarRouter = r;

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

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

View File

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

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

@@ -0,0 +1,148 @@
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';
//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", 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) => {
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);
}
})
router.get('/all', async (req, res) => {
try {
const result = await getAllLOA();
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', 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', 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);
}
})
module.exports = router;

View File

@@ -1,9 +1,12 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
import { Request, Response } from 'express';
import pool from '../db'; import pool from '../db';
import { getUserData } from '../services/memberService'; import { getUserActiveLOA } from '../services/loaService';
import { getMemberSettings, getMembersFull, getMembersLite, getUserData, setUserSettings } from '../services/memberService';
import { getUserRoles } from '../services/rolesService'; import { getUserRoles } from '../services/rolesService';
import { memberSettings } from '@app/shared/types/member';
router.use((req, res, next) => { router.use((req, res, next) => {
console.log(req.user); console.log(req.user);
@@ -26,7 +29,7 @@ router.get('/', async (req, res) => {
AND UTC_TIMESTAMP() BETWEEN l.start_date AND l.end_date AND UTC_TIMESTAMP() BETWEEN l.start_date AND l.end_date
) THEN 1 ELSE 0 ) THEN 1 ELSE 0
END AS on_loa END AS on_loa
FROM view_member_rank_status_all v;`); FROM view_member_rank_unit_status_latest v;`);
return res.status(200).json(result); return res.status(200).json(result);
} catch (err) { } catch (err) {
console.error('Error fetching users:', err); console.error('Error fetching users:', err);
@@ -40,12 +43,13 @@ router.get('/me', async (req, res) => {
try { try {
const { id, name, state } = await getUserData(req.user.id); const { id, name, state } = await getUserData(req.user.id);
const LOAData = await pool.query( // const LOAData = await pool.query(
`SELECT * // `SELECT *
FROM leave_of_absences // FROM leave_of_absences
WHERE member_id = ? // WHERE member_id = ?
AND deleted = 0 // AND deleted = 0
AND UTC_TIMESTAMP() BETWEEN start_date AND end_date;`, req.user.id); // AND UTC_TIMESTAMP() BETWEEN start_date AND end_date;`, req.user.id);
const LOAData = await getUserActiveLOA(req.user.id);
const roleData = await getUserRoles(req.user.id); const roleData = await getUserRoles(req.user.id);
@@ -58,10 +62,57 @@ router.get('/me', async (req, res) => {
} }
}) })
router.get('/settings', 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', 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.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', async (req, res) => { router.get('/:id', async (req, res) => {
try { try {
const userId = req.params.id; const userId = req.params.id;
const result = await pool.query('SELECT * FROM view_member_rank_status_all WHERE id = $1;', [userId]); const result = await pool.query('SELECT * FROM view_member_rank_unit_status_latest WHERE id = $1;', [userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' }); return res.status(404).json({ error: 'User not found' });
} }
@@ -72,13 +123,4 @@ router.get('/:id', async (req, res) => {
} }
}); });
//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; module.exports = router;

View File

@@ -49,7 +49,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
`); `);

View File

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

View File

@@ -1,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.*,
@@ -44,7 +46,20 @@ export async function getApplicationList(): Promise<ApplicationListRow[]> {
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) {
const sql = ` const sql = `
UPDATE applications UPDATE applications
SET approved_at = NOW() SET approved_at = NOW()
@@ -57,15 +72,38 @@ export async function approveApplication(id) {
return result; return result;
} }
export async function getApplicationComments(appID: number): Promise<CommentRow[]> { export async function denyApplication(id: number) {
const sql = `
UPDATE applications
SET denied_at = NOW()
WHERE id = ?
AND approved_at IS NULL
AND denied_at IS NULL
`;
const result = await pool.execute(sql, id);
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

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,12 @@
import pool from '../db'; import pool from '../db';
import { CalendarEventShort, CalendarSignup, CalendarEvent, CalendarAttendance } from "@app/shared/types/calendar"
export interface CalendarEvent { import { toDateTime } from "@app/shared/utils/time"
id: number;
name: string;
start: Date; // DATETIME -> Date
end: Date; // DATETIME -> Date
location: string;
color: string; // 7 character hex string
description?: string | null;
creator?: number | null; // foreign key to members.id, nullable
cancelled: boolean; // TINYINT(1) -> boolean
created_at: Date; // TIMESTAMP -> Date
updated_at: Date; // TIMESTAMP -> Date
}
export type Attendance = 'attending' | 'maybe' | 'not_attending';
export async function createEvent(eventObject: Omit<CalendarEvent, 'id' | 'created_at' | 'updated_at' | 'cancelled'>) { export async function createEvent(eventObject: Omit<CalendarEvent, 'id' | 'created_at' | 'updated_at' | 'cancelled'>) {
const sql = ` const sql = `
INSERT INTO calendar_events INSERT INTO calendar_events
(name, start, end, location, color, description, creator) (name, start, end, location, color, description, creator)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`; `;
const params = [ const params = [
eventObject.name, eventObject.name,
@@ -29,7 +15,7 @@ export async function createEvent(eventObject: Omit<CalendarEvent, 'id' | 'creat
eventObject.location, eventObject.location,
eventObject.color, eventObject.color,
eventObject.description ?? null, eventObject.description ?? null,
eventObject.creator, eventObject.creator_id,
]; ];
const result = await pool.query(sql, params); const result = await pool.query(sql, params);
@@ -40,7 +26,6 @@ export async function updateEvent(eventObject: CalendarEvent) {
if (!eventObject.id) { if (!eventObject.id) {
throw new Error("updateEvent: Missing event ID."); throw new Error("updateEvent: Missing event ID.");
} }
const sql = ` const sql = `
UPDATE calendar_events UPDATE calendar_events
SET SET
@@ -49,14 +34,14 @@ export async function updateEvent(eventObject: CalendarEvent) {
end = ?, end = ?,
location = ?, location = ?,
color = ?, color = ?,
description = ?, description = ?
WHERE id = ? WHERE id = ?
`; `;
const params = [ const params = [
eventObject.name, eventObject.name,
eventObject.start, toDateTime(eventObject.start),
eventObject.end, toDateTime(eventObject.end),
eventObject.location, eventObject.location,
eventObject.color, eventObject.color,
eventObject.description ?? null, eventObject.description ?? null,
@@ -67,28 +52,30 @@ export async function updateEvent(eventObject: CalendarEvent) {
return { success: true }; return { success: true };
} }
export async function cancelEvent(eventID: number) { export async function setEventCancelled(eventID: number, cancelled: boolean) {
const input = cancelled ? 1 : 0;
const sql = ` const sql = `
UPDATE calendar_events UPDATE calendar_events
SET cancelled = 1 SET cancelled = ?
WHERE id = ? WHERE id = ?
`; `;
await pool.query(sql, [eventID]); await pool.query(sql, [input, eventID]);
return { success: true }; return { success: true };
} }
export async function getShortEventsInRange(startDate: Date, endDate: Date) { export async function getShortEventsInRange(startDate: string, endDate: string): Promise<CalendarEventShort[]> {
const sql = ` const sql = `
SELECT id, name, start, end, color SELECT id, name, start, end, color, cancelled, full_day
FROM calendar_events FROM calendar_events
WHERE start BETWEEN ? AND ? WHERE start BETWEEN ? AND ?
ORDER BY start ASC ORDER BY start ASC
`; `;
return await pool.query(sql, [startDate, endDate]); const res: CalendarEventShort[] = await pool.query(sql, [startDate, endDate]);
return res;
} }
export async function getEventDetails(eventID: number) { export async function getEventDetails(eventID: number): Promise<CalendarEvent> {
const sql = ` const sql = `
SELECT SELECT
e.id, e.id,
@@ -101,14 +88,14 @@ export async function getEventDetails(eventID: number) {
e.cancelled, e.cancelled,
e.created_at, e.created_at,
e.updated_at, e.updated_at,
m.id AS creator_id, e.creator AS creator_id,
m.name AS creator_name m.name AS creator_name
FROM calendar_events e FROM calendar_events e
LEFT JOIN members m ON e.creator = m.id LEFT JOIN members m ON e.creator = m.id
WHERE e.id = ? WHERE e.id = ?
`; `;
let vals: CalendarEvent[] = await pool.query(sql, [eventID]);
return await pool.query(sql, [eventID]) return vals[0];
} }
export async function getUpcomingEvents(date: Date, limit: number) { export async function getUpcomingEvents(date: Date, limit: number) {
@@ -124,7 +111,7 @@ export async function getUpcomingEvents(date: Date, limit: number) {
} }
export async function setAttendanceStatus(memberID: number, eventID: number, status: Attendance) { export async function setAttendanceStatus(memberID: number, eventID: number, status: CalendarAttendance) {
const sql = ` const sql = `
INSERT INTO calendar_events_signups (member_id, event_id, status) INSERT INTO calendar_events_signups (member_id, event_id, status)
VALUES (?, ?, ?) VALUES (?, ?, ?)
@@ -135,16 +122,10 @@ export async function setAttendanceStatus(memberID: number, eventID: number, sta
return { success: true } return { success: true }
} }
export async function getEventAttendance(eventID: number) { export async function getEventAttendance(eventID: number): Promise<CalendarSignup[]> {
const sql = `
SELECT
s.member_id,
s.status,
m.name AS member_name
FROM calendar_events_signups s
LEFT JOIN members m ON s.member_id = m.id
WHERE s.event_id = ?
`;
return await pool.query(sql, [eventID]); const sql = "CALL `sp_GetCalendarEventSignups`(?)"
const res = await pool.query(sql, [eventID]);
console.log(res[0]);
return res[0];
} }

View File

@@ -0,0 +1,98 @@
import { toDateTime } from "@app/shared/utils/time";
import pool from "../db";
import { LOARequest, LOAType } from '@app/shared/types/loa'
export async function getLoaTypes(): Promise<LOAType[]> {
return await pool.query('SELECT * FROM leave_of_absences_types;');
}
export async function getAllLOA(page = 1, pageSize = 20): Promise<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 res: LOARequest[] = await pool.query(sql, [pageSize, offset]) as LOARequest[];
return res;
}
export async function getUserLOA(userId: number): Promise<LOARequest[]> {
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
`, [userId])
return result;
}
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 = ?
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,23 +1,63 @@
import pool from "../db"; import pool from "../db";
import { Member, MemberLight, memberSettings, MemberState } from '@app/shared/types/member'
export enum MemberState {
Guest = "guest",
Applicant = "applicant",
Member = "member",
Retired = "retired",
Banned = "banned",
Denied = "denied"
}
export async function getUserData(userID: number) { export async function getUserData(userID: number) {
const sql = `SELECT * FROM members WHERE id = ?`; const sql = `SELECT * FROM members WHERE id = ?`;
const res = await pool.query(sql, [userID]); const res = await pool.query(sql, [userID]);
return res[0] ?? null; 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 {
namespace Express {
interface Request {
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 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;
} }

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

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

15
ecosystem.config.js Normal file
View File

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -0,0 +1,60 @@
import { z } from "zod";
export const courseEventAttendeeSchema = z.object({
attendee_id: z.number({ invalid_type_error: "Must select a member" }).int().positive(),
passed_bookwork: z.boolean(),
passed_qual: z.boolean(),
remarks: z.string(),
attendee_role_id: z.number({ invalid_type_error: "Must select a role" }).int().positive()
})
export const trainingReportSchema = z.object({
id: z.number().int().positive().optional(),
course_id: z.number({ invalid_type_error: "Must select a training" }).int(),
event_date: z
.string()
.refine(
(val) => !isNaN(Date.parse(val)),
"Must be a valid date"
),
remarks: z.string().nullable().optional(),
attendees: z.array(courseEventAttendeeSchema).default([]),
}).superRefine((data, ctx) => {
const trainerRole = 1;
const traineeRole = 2;
const hasTrainer = data.attendees.some((a) => a.attendee_role_id === trainerRole);
const hasTrainee = data.attendees.some((a) => a.attendee_role_id === traineeRole);
if (!hasTrainer) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["attendees"],
message: "At least one Primary Trainer is required.",
});
}
if (!hasTrainee) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["attendees"],
message: "At least one Trainee is required.",
});
}
//no duplicates
const idCounts = new Map<number, number>();
data.attendees.forEach((a, index) => {
idCounts.set(a.attendee_id, (idCounts.get(a.attendee_id) ?? 0) + 1);
if (idCounts.get(a.attendee_id)! > 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["attendees"],
message: "Cannot have duplicate attendee.",
});
}
})
})

17
shared/tsconfig.json Normal file
View File

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

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 {

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

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

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

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

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

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

@@ -0,0 +1,31 @@
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;
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
}

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

@@ -0,0 +1,6 @@
export interface Role {
id: number;
name: string;
color?: string;
description?: string;
}

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

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

10
ui/.env.example Normal file
View File

@@ -0,0 +1,10 @@
# SITE SETTINGS
VITE_APIHOST=
VITE_DOCHOST= # https://bookstack.whatever.com/api
VITE_ENVIRONMENT= # dev / prod
VITE_APPLICATION_VERSION= # Should match release tag
# Glitchtip
VITE_GLITCHTIP_DSN=
VITE_DISABLE_GLITCHTIP= # true/false

106
ui/package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@fullcalendar/interaction": "^6.1.19", "@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19",
"@fullcalendar/vue3": "^6.1.19", "@fullcalendar/vue3": "^6.1.19",
"@sentry/vue": "^10.27.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
@@ -21,7 +22,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.539.0", "lucide-vue-next": "^0.539.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"reka-ui": "^2.5.0", "reka-ui": "^2.6.1",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",
@@ -1392,6 +1393,103 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sentry-internal/browser-utils": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.27.0.tgz",
"integrity": "sha512-17tO6AXP+rmVQtLJ3ROQJF2UlFmvMWp7/8RDT5x9VM0w0tY31z8Twc0gw2KA7tcDxa5AaHDUbf9heOf+R6G6ow==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.27.0.tgz",
"integrity": "sha512-UecsIDJcv7VBwycge/MDvgSRxzevDdcItE1i0KSwlPz00rVVxLY9kV28PJ4I2E7r6/cIaP9BkbWegCEcv09NuA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.27.0.tgz",
"integrity": "sha512-tKSzHq1hNzB619Ssrqo25cqdQJ84R3xSSLsUWEnkGO/wcXJvpZy94gwdoS+KmH18BB1iRRRGtnMxZcUkiPSesw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.27.0",
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.27.0.tgz",
"integrity": "sha512-inhsRYSVBpu3BI1kZphXj6uB59baJpYdyHeIPCiTfdFNBE5tngNH0HS/aedZ1g9zICw290lwvpuyrWJqp4VBng==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.27.0",
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.27.0.tgz",
"integrity": "sha512-G8q362DdKp9y1b5qkQEmhTFzyWTOVB0ps1rflok0N6bVA75IEmSDX1pqJsNuY3qy14VsVHYVwQBJQsNltQLS0g==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.27.0",
"@sentry-internal/feedback": "10.27.0",
"@sentry-internal/replay": "10.27.0",
"@sentry-internal/replay-canvas": "10.27.0",
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.27.0.tgz",
"integrity": "sha512-Zc68kdH7tWTDtDbV1zWIbo3Jv0fHAU2NsF5aD2qamypKgfSIMSbWVxd22qZyDBkaX8gWIPm/0Sgx6aRXRBXrYQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/vue": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-10.27.0.tgz",
"integrity": "sha512-vQVxnw59jRe5WsdB9ad/WpMPQ93QXE6Y0JEy01xIRcDlQ1pXp5wuxLkKGuTfvjdQzVUGIBLr0CgIqRAmPRymVg==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.27.0",
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"pinia": "2.x || 3.x",
"vue": "2.x || 3.x"
},
"peerDependenciesMeta": {
"pinia": {
"optional": true
}
}
},
"node_modules/@sindresorhus/merge-streams": { "node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@@ -3235,9 +3333,9 @@
} }
}, },
"node_modules/reka-ui": { "node_modules/reka-ui": {
"version": "2.5.0", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz", "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.1.tgz",
"integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==", "integrity": "sha512-XK7cJDQoNuGXfCNzBBo/81Yg/OgjPwvbabnlzXG2VsdSgNsT6iIkuPBPr+C0Shs+3bb0x0lbPvgQAhMSCKm5Ww==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",

View File

@@ -17,6 +17,7 @@
"@fullcalendar/interaction": "^6.1.19", "@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19",
"@fullcalendar/vue3": "^6.1.19", "@fullcalendar/vue3": "^6.1.19",
"@sentry/vue": "^10.27.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
@@ -25,7 +26,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.539.0", "lucide-vue-next": "^0.539.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"reka-ui": "^2.5.0", "reka-ui": "^2.6.1",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",

BIN
ui/public/17RBN_Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

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

View File

@@ -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}`) 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),
}) })
@@ -121,8 +68,28 @@ export async function getAllApplications(): Promise<ApplicationFull> {
} }
} }
export async function loadMyApplications(): Promise<ApplicationFull> {
const res = await fetch(`${addr}/application/meList`, { credentials: 'include' })
if (res.ok) {
return res.json()
} else {
console.error("Something went wrong approving the application")
}
}
export async function getMyApplication(id: number): Promise<ApplicationFull> {
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) { export async function approveApplication(id: Number) {
const res = await fetch(`${addr}/application/approve/${id}`, { method: 'POST' }) const res = await fetch(`${addr}/application/approve/${id}`, { method: 'POST', credentials: 'include' })
if (!res.ok) { if (!res.ok) {
console.error("Something went wrong approving the application") console.error("Something went wrong approving the application")
@@ -130,9 +97,36 @@ export async function approveApplication(id: Number) {
} }
export async function denyApplication(id: Number) { export async function denyApplication(id: Number) {
const res = await fetch(`${addr}/application/deny/${id}`, { method: 'POST' }) const res = await fetch(`${addr}/application/deny/${id}`, { method: 'POST', credentials: 'include' })
if (!res.ok) { if (!res.ok) {
console.error("Something went wrong denying the application") console.error("Something went wrong denying the application")
} }
}
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

@@ -1,13 +1,13 @@
export interface CalendarEvent { // export interface CalendarEvent {
name: string, // name: string,
start: Date, // start: Date,
end: Date, // end: Date,
location: string, // location: string,
color: string, // color: string,
description: string, // description: string,
creator: any | null, // user object // creator: any | null, // user object
id: number | null // id: number | null
} // }
export enum CalendarAttendance { export enum CalendarAttendance {
Attending = "attending", Attending = "attending",
@@ -21,22 +21,107 @@ export interface CalendarSignup {
state: CalendarAttendance state: CalendarAttendance
} }
export async function createCalendarEvent(eventData: CalendarEvent) { import { CalendarEventShort, CalendarEvent } from "@shared/types/calendar";
//@ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getMonthCalendarEvents(viewedMonth: Date): Promise<CalendarEventShort[]> {
const year = viewedMonth.getFullYear();
const month = viewedMonth.getMonth();
// Base range: first and last day of the month
const firstOfMonth = new Date(year, month, 1);
const lastOfMonth = new Date(year, month + 1, 0);
// --- Apply 10 day padding ---
const start = new Date(firstOfMonth);
start.setDate(start.getDate() - 10);
const end = new Date(lastOfMonth);
end.setDate(end.getDate() + 10);
end.setHours(23, 59, 59, 999);
const from = start.toISOString();
const to = end.toISOString();
const url = `${addr}/calendar?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch events: ${res.status} ${res.statusText}`);
}
return res.json();
}
export async function getCalendarEvent(id: number): Promise<CalendarEvent> {
let res = await fetch(`${addr}/calendar/${id}`);
if (res.ok) {
return await res.json();
} else {
throw new Error(`Failed to fetch event: ${res.status} ${res.statusText}`);
}
}
export async function createCalendarEvent(eventData: CalendarEvent) {
let res = await fetch(`${addr}/calendar`, {
method: "POST",
credentials: "include",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(eventData)
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }
export async function editCalendarEvent(eventData: CalendarEvent) { export async function editCalendarEvent(eventData: CalendarEvent) {
let res = await fetch(`${addr}/calendar`, {
method: "PUT",
credentials: "include",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(eventData)
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }
export async function cancelCalendarEvent(eventID: number) { export async function setCancelCalendarEvent(eventID: number, cancel: boolean) {
let route = cancel ? "cancel" : "uncancel";
} console.log(route);
let res = await fetch(`${addr}/calendar/${eventID}/${route}`, {
method: "POST",
credentials: "include"
});
export async function adminCancelCalendarEvent(eventID: number) { if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }
export async function setCalendarEventAttendance(eventID: number, state: CalendarAttendance) { export async function setCalendarEventAttendance(eventID: number, state: CalendarAttendance) {
let res = await fetch(`${addr}/calendar/${eventID}/attendance?state=${state}`, {
method: "POST",
credentials: "include",
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }

View File

@@ -1,12 +1,4 @@
export type LOARequest = { import { LOARequest, LOAType } from '@shared/types/loa'
id?: number;
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,6 +9,24 @@ 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) {
return;
} else {
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) { if (res.ok) {
@@ -26,6 +36,7 @@ export async function submitLOA(request: LOARequest): Promise<{ id?: number; err
} }
} }
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",
@@ -60,3 +71,84 @@ export function getAllLOAs(): Promise<LOARequest[]> {
} }
}); });
} }
export function getMyLOAs(): Promise<LOARequest[]> {
return fetch(`${addr}/loa/history`, {
method: "GET",
credentials: 'include',
headers: {
"Content-Type": "application/json",
},
}).then((res) => {
if (res.ok) {
return res.json();
} else {
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,66 @@ 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 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

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

View File

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

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

View File

@@ -11,13 +11,19 @@ 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 = defineProps<{
messages: Array<Record<string, any>> messages: CommentRow[]
}>() }>()
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 +32,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>
@@ -38,7 +49,7 @@ function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => vo
<Form :validation-schema="commentSchema" @submit="onSubmit"> <Form :validation-schema="commentSchema" @submit="onSubmit">
<FormField name="text" v-slot="{ componentField }"> <FormField name="text" v-slot="{ componentField }">
<FormItem> <FormItem>
<FormLabel class="sr-only">Comment</FormLabel> <FormLabel>Comment</FormLabel>
<FormControl> <FormControl>
<Textarea v-bind="componentField" rows="3" placeholder="Write a comment…" <Textarea v-bind="componentField" rows="3" placeholder="Write a comment…"
class="bg-neutral-800 resize-none w-full" /> class="bg-neutral-800 resize-none w-full" />
@@ -48,25 +59,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 type="submit" @click="submitMode = 'internal'" variant="outline">Post (Internal)</Button>
<Button type="submit" @click="submitMode = 'public'">Post (Public)</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.coerce.number({ invalid_type_error: "Must be a number", }).min(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>
@@ -131,7 +178,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>
@@ -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

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

View File

@@ -0,0 +1,292 @@
<script setup lang="ts">
import type { CalendarEvent, CalendarSignup } from '@shared/types/calendar'
import { CircleAlert, Clock4, EllipsisVertical, MapPin, User, X } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
import Button from '../ui/button/Button.vue';
import { CalendarAttendance, getCalendarEvent, setCalendarEventAttendance, setCancelCalendarEvent } from '@/api/calendar';
import { useUserStore } from '@/stores/user';
import { useRoute } from 'vue-router';
import DropdownMenu from '../ui/dropdown-menu/DropdownMenu.vue';
import DropdownMenuTrigger from '../ui/dropdown-menu/DropdownMenuTrigger.vue';
import DropdownMenuContent from '../ui/dropdown-menu/DropdownMenuContent.vue';
import DropdownMenuItem from '../ui/dropdown-menu/DropdownMenuItem.vue';
import { Calendar } from 'lucide-vue-next';
import MemberCard from '../members/MemberCard.vue';
const route = useRoute();
const loaded = ref<boolean>(false);
const activeEvent = ref<CalendarEvent | null>(null);
watch(
() => route.params.id,
async (id) => {
if (!id) return;
activeEvent.value = await getCalendarEvent(Number(id));
loaded.value = true;
},
{ immediate: true }
);
const emit = defineEmits<{
(e: 'close'): void
(e: 'reload'): void
(e: 'edit', event: CalendarEvent): void
}>()
const dateFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'long', month: 'short', day: 'numeric',
})
const timeFmt = new Intl.DateTimeFormat(undefined, {
hour: 'numeric', minute: '2-digit'
})
const dateText = computed(() => {
let start = dateFmt.format(new Date(activeEvent.value.start));
let end = dateFmt.format(new Date(activeEvent.value.end));
if (start === end)
return start;
else
return `${start} - ${end}`;
})
const timeText = computed(() => {
let startTime = timeFmt.format(new Date(activeEvent.value.start))
let endTime = timeFmt.format(new Date(activeEvent.value.end))
return [startTime, endTime]
})
const attending = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Attending) })
const maybe = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Maybe) })
const declined = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.NotAttending) })
const viewedState = ref<CalendarAttendance>(CalendarAttendance.Attending);
let user = useUserStore();
const myAttendance = computed<CalendarSignup | null>(() => {
if (!user.isLoggedIn) return null;
return activeEvent.value.eventSignups.find(
(s) => s.member_id === user.user.id
) || null;
});
async function setAttendance(state: CalendarAttendance) {
await setCalendarEventAttendance(activeEvent.value.id, state);
//refresh event data
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
}
const canEditEvent = computed(() => {
if (!user.isLoggedIn) return false;
if (user.user.id == activeEvent.value.creator_id)
return true;
});
async function setCancel(isCancelled: boolean) {
await setCancelCalendarEvent(activeEvent.value.id, isCancelled);
emit("reload");
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
}
async function forceReload() {
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
}
const isPast = computed(() => {
const end = new Date(activeEvent.value.end)
// is current date later than end date
return new Date() < end;
})
const attendanceTab = ref<"Alpha" | "Echo" | "Other">("Alpha");
const attendanceList = computed<CalendarSignup[]>(() => {
let out: CalendarSignup[] = [];
if (attendanceTab.value === 'Alpha') {
out = activeEvent.value.eventSignups?.filter((s) => s.member_unit === 'Alpha Company');
} else if (attendanceTab.value === 'Echo') {
out = activeEvent.value.eventSignups?.filter((s) => s.member_unit === 'Echo Company')
} else {
out = activeEvent.value.eventSignups?.filter((s) => s.member_unit != 'Alpha Company' && s.member_unit != 'Echo Company')
}
const statusOrder: Record<CalendarAttendance, number> = {
[CalendarAttendance.Attending]: 1,
[CalendarAttendance.Maybe]: 2,
[CalendarAttendance.NotAttending]: 3,
};
out.sort((a, b) => statusOrder[a.status] - statusOrder[b.status]);
return out;
})
const attendanceCountsByGroup = computed(() => {
const signups = activeEvent.value.eventSignups ?? [];
return {
Alpha: signups.filter(s => s.member_unit === "Alpha Company").length,
Echo: signups.filter(s => s.member_unit === "Echo Company").length,
Other: signups.filter(s =>
s.member_unit !== "Alpha Company" &&
s.member_unit !== "Echo Company"
).length,
};
});
const attendanceStatusSummary = computed(() => {
const signups = activeEvent.value.eventSignups ?? [];
return {
attending: signups.filter(s => s.status === CalendarAttendance.Attending).length,
maybe: signups.filter(s => s.status === CalendarAttendance.Maybe).length,
notAttending: signups.filter(s => s.status === CalendarAttendance.NotAttending).length,
};
});
const statusColor = (status: CalendarAttendance) => {
switch (status) {
case CalendarAttendance.Attending:
return "text-success";
case CalendarAttendance.Maybe:
return "text-yellow-600";
case CalendarAttendance.NotAttending:
return "text-destructive";
default:
return "";
}
};
const displayStatus = (status: CalendarAttendance) => {
switch (status) {
case CalendarAttendance.Attending:
return "Attending";
case CalendarAttendance.Maybe:
return "Maybe";
case CalendarAttendance.NotAttending:
return "Declined";
default:
return status;
}
};
defineExpose({ forceReload })
</script>
<template>
<div v-if="loaded">
<!-- Header -->
<div class="flex items-center justify-between gap-3 border-b px-4 py-3 h-14">
<h2 class="text-lg font-semibold break-all">
{{ activeEvent?.name || 'Event' }}
</h2>
<div class="flex gap-4 items-center">
<DropdownMenu v-if="canEditEvent">
<DropdownMenuTrigger>
<button
class="inline-flex flex-none size-8 items-center justify-center cursor-pointer rounded-md hover:bg-muted/40 transition">
<EllipsisVertical class="size-6" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="emit('edit', activeEvent)">
Edit
</DropdownMenuItem>
<DropdownMenuItem v-if="activeEvent.cancelled" @click="setCancel(false)">
Un-Cancel
</DropdownMenuItem>
<DropdownMenuItem v-else @click="setCancel(true)">
Cancel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
class="inline-flex flex-none size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition cursor-pointer"
aria-label="Close" @click="emit('close')">
<X class="size-4" />
</button>
</div>
</div>
<!-- Body -->
<div class="flex-1 flex flex-col items-center min-h-0 overflow-y-auto px-4 py-4 space-y-6 w-full">
<section v-if="activeEvent.cancelled == true" class="w-full">
<div class="flex p-2 rounded-md w-full bg-destructive gap-3">
<CircleAlert></CircleAlert> This event has been cancelled
</div>
</section>
<section v-if="isPast && user.isLoggedIn" class="w-full">
<ButtonGroup class="flex w-full">
<Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.Attending)">Going</Button>
<Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.Maybe ? 'border-2 !border-l-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.Maybe)">Maybe</Button>
<Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.NotAttending ? 'border-2 !border-l-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.NotAttending)">Declined</Button>
</ButtonGroup>
</section>
<!-- Meta -->
<section class="space-y-3 w-full">
<p class="text-lg font-semibold">Details</p>
<div class="text-foreground/80 flex gap-3 items-center">
<Calendar :size="20"></Calendar> {{ dateText }}
</div>
<div class="text-foreground/80 flex gap-3 items-center">
<Clock4 :size="20"></Clock4> {{ timeText[0] }} - {{ timeText[1] }}
</div>
<div class="text-foreground/80 flex gap-3 items-center">
<MapPin :size="20"></MapPin> {{ activeEvent.location || "Unknown" }}
</div>
<div class="text-foreground/80 flex gap-3 items-center">
<User :size="20"></User> <MemberCard :member-id="activeEvent.creator_id"></MemberCard>
</div>
</section>
<!-- Description -->
<section class="space-y-2 w-full">
<p class="text-lg font-semibold">Description</p>
<p class="border bg-muted/50 px-3 py-2 rounded-lg min-h-24 my-2 whitespace-pre-line">
{{ activeEvent.description }}
</p>
</section>
<!-- attendance -->
<section class="space-y-2 w-full">
<div class="flex items-center gap-5">
<p class="text-lg font-semibold">Attendance</p>
<!-- <div class="text-muted-foreground flex gap-6">
<p>Going <span class="ml-1">{{ attendanceStatusSummary.attending }}</span></p>
<p>Maybe <span class="ml-1">{{ attendanceStatusSummary.maybe }}</span></p>
<p>Declined <span class="ml-1">{{ attendanceStatusSummary.notAttending }}</span></p>
</div> -->
</div>
<div class="flex flex-col border bg-muted/50 rounded-lg min-h-24 my-2">
<div class="flex w-full pt-2 border-b *:w-full *:text-center *:pb-1 *:cursor-pointer">
<label :class="attendanceTab === 'Alpha' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="attendanceTab = 'Alpha'">Alpha {{ attendanceCountsByGroup.Alpha }}</label>
<label :class="attendanceTab === 'Echo' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="attendanceTab = 'Echo'">Echo {{ attendanceCountsByGroup.Echo }}</label>
<label :class="attendanceTab === 'Other' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="attendanceTab = 'Other'">Other {{ attendanceCountsByGroup.Other }}</label>
</div>
<div class="pb-1 min-h-48">
<div class="grid grid-cols-2 font-semibold text-muted-foreground border-b py-1 px-3 mb-2">
<p>Name</p>
<p class="text-right">Status</p>
</div>
<div v-for="person in attendanceList" :key="person.member_id"
class="grid grid-cols-2 py-1 *:px-3 hover:bg-muted">
<div>
<MemberCard :member-id="person.member_id"></MemberCard>
</div>
<p :class="statusColor(person.status)" class="text-right">
{{ displayStatus(person.status) }}
</p>
</div>
</div>
</div>
</section>
</div>
</div>
</template>

View File

@@ -1,26 +1,47 @@
<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 { Member, getMembers } from "@/api/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 +52,226 @@ 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 onSubmit = handleSubmit(async (values) => {
// start: new CalendarDate(2022, 1, 20), console.log(values);
// end: new CalendarDate(2022, 1, 20).add({ days: 20 }), const out: LOARequest = {
}) as Ref<DateRange> member_id: values.member_id,
start_date: values.start_date,
const reason = ref(""); // <-- reason for LOA end_date: values.end_date,
const submitting = ref(false); type_id: values.type.id,
const submitError = ref<string | null>(null); reason: values.reason
const submitSuccess = ref(false); };
if (props.adminMode) {
await adminSubmitLOA(out);
} else {
await submitLOA(out);
userStore.loadUser();
}
})
onMounted(async () => { onMounted(async () => {
if (props.member) { if (props.member) {
currentMember.value = props.member; currentMember.value = props.member;
} }
if (props.adminMode) { try {
members.value = await getMembers(); if (!props.adminMode) {
let policy = await getLoaPolicy() as any;
policyString.value = policy;
policyRef.value.innerHTML = policyString.value;
}
} catch (error) {
console.error(error);
} }
members.value = await getMembers(); 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;
}
})
</script> </script>
<template> <template>
<div class="flex flex-row-reverse gap-6 mx-auto " :class="!adminMode ? 'max-w-5xl' : 'max-w-5xl'"> <div class="flex flex-row-reverse gap-6 mx-auto w-full" :class="!adminMode ? 'max-w-5xl' : 'max-w-5xl'">
<div v-if="!adminMode" class="flex-1 flex space-x-4 rounded-md border p-4"> <div v-if="!adminMode" class="flex-1 flex 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 @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.member_name : ''
<ComboboxItemIndicator class="absolute left-2 inline-flex items-center"> }" />
<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> <template v-for="member in members" :key="member.member_id">
</ComboboxList> <ComboboxItem :value="member.member_id"
</Combobox> class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5 w-full">
<Popover> {{ member.member_name }}
<PopoverTrigger as-child> <ComboboxItemIndicator
<Button variant="outline" :class="cn( class="absolute left-2 inline-flex items-center">
'w-1/2 justify-start text-left font-normal', <Check class="h-4 w-4" />
!value && 'text-muted-foreground', </ComboboxItemIndicator>
)"> </ComboboxItem>
<CalendarIcon class="mr-2 h-4 w-4" /> </template>
<template v-if="value.start"> </ComboboxGroup>
<template v-if="value.end"> </ComboboxList>
{{ df.format(value.start.toDate(getLocalTimeZone())) }} - {{ </Combobox>
df.format(value.end.toDate(getLocalTimeZone())) }} <div class="h-4">
</template> <FieldError v-if="errors.length" :errors="errors"></FieldError>
<template v-else> </div>
{{ df.format(value.start.toDate(getLocalTimeZone())) }} </FieldContent>
</template> </Field>
</template> </VeeField>
<template v-else> <VeeField v-slot="{ field, errors }" name="type">
Pick a date <Field class="w-full">
</template> <FieldContent>
</Button> <FieldLabel>Type</FieldLabel>
</PopoverTrigger> <Select :model-value="field.value" @update:model-value="field.onChange">
<PopoverContent class="w-auto p-0"> <SelectTrigger class="w-full">
<RangeCalendar v-model="value" initial-focus :number-of-months="2" <SelectValue placeholder="Select type"></SelectValue>
@update:start-value="(startDate) => value.start = startDate" /> </SelectTrigger>
</PopoverContent> <SelectContent>
</Popover> <SelectItem v-for="type in loaTypes" :value="type">
</div> {{ type.name }}
<Textarea v-model="reason" placeholder="Reason for LOA" class="w-full resize-none" /> </SelectItem>
<div class="flex justify-end"> </SelectContent>
<Button :onClick="handleSubmit" :disabled="submitting" class="w-min">Submit</Button> </Select>
</div> <div class="h-4">
<div v-if="submitError" class="text-red-500 text-sm mt-2">{{ submitError }}</div> <FieldError v-if="errors.length" :errors="errors"></FieldError>
<div v-if="submitSuccess" class="text-green-500 text-sm mt-2">LOA submitted successfully!</div> </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> </div>
</div> </div>
</template> </template>

View File

@@ -16,30 +16,54 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Ellipsis } from "lucide-vue-next"; import { Ellipsis } 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";
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", { LOAList.value = await getAllLOAs();
} else {
LOAList.value = await getMyLOAs();
}
}
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,9 +71,9 @@ 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[] { function sortByStartDate(loas: LOARequest[]): LOARequest[] {
@@ -58,50 +82,108 @@ function sortByStartDate(loas: LOARequest[]): LOARequest[] {
); );
} }
const sortedLoas = computed(() => sortByStartDate(LOAList.value)); async function cancelAndReload(id: number) {
await cancelLOA(id, props.adminMode);
await loadLOAs();
}
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();
}
</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>
</TableRow> <TableRow v-for="post in LOAList" :key="post.id" class="hover:bg-muted/50">
</TableBody> <TableCell class="font-medium">
</Table> <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">
<Ellipsis></Ellipsis>
</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)">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>
</TableRow>
</TableBody>
</Table>
</div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,153 @@
<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';
// 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">
Loading profile
</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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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,45 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
import NavigationMenuViewport from "./NavigationMenuViewport.vue";
const props = defineProps({
modelValue: { type: String, required: false },
defaultValue: { type: String, required: false },
dir: { type: String, required: false },
orientation: { type: String, required: false },
delayDuration: { type: Number, required: false },
skipDelayDuration: { type: Number, required: false },
disableClickTrigger: { type: Boolean, required: false },
disableHoverTrigger: { type: Boolean, required: false },
disablePointerLeaveClose: { type: Boolean, required: false },
unmountOnHide: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
viewport: { type: Boolean, required: false, default: true },
});
const emits = defineEmits(["update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class", "viewport");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<NavigationMenuRoot
v-slot="slotProps"
data-slot="navigation-menu"
:data-viewport="viewport"
v-bind="forwarded"
:class="
cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
props.class,
)
"
>
<slot v-bind="slotProps" />
<NavigationMenuViewport v-if="viewport" />
</NavigationMenuRoot>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuList, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<NavigationMenuList
data-slot="navigation-menu-list"
v-bind="forwardedProps"
:class="
cn(
'group flex flex-1 list-none items-center justify-center gap-1',
props.class,
)
"
>
<slot />
</NavigationMenuList>
</template>

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