5 Commits

152 changed files with 5708 additions and 7954 deletions

View File

@@ -1,95 +0,0 @@
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 fetch --tags
sudo -u nginx git pull origin main
new_version=`git log -1 --format=%H`
echo "Successfully updated to: $new_version"
- name: Update Shared Dependencies and Fix Permissions
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

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

2
.gitignore vendored
View File

@@ -32,3 +32,5 @@ coverage
*.sql *.sql
.env .env
*.db *.db
db_data

12
api/.env.development Normal file
View File

@@ -0,0 +1,12 @@
# DATABASE SETTINGS
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=ranger_unit_tracker
DB_USERNAME=dev
DB_PASSWORD=dev
# AUTH SETTINGS
AUTH_MODE=mock # mock bypasses authentik
# SERVER SETTINGS
SERVER_PORT=3000

View File

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

2
api/.gitignore vendored
View File

@@ -1 +1,3 @@
built built
!migrations/*.sql

View File

@@ -0,0 +1,609 @@
-- --------------------------------------------------------
-- Host: gs.iceberg-gaming.com
-- Server version: 10.6.5-MariaDB - mariadb.org binary distribution
-- Server OS: Win64
-- HeidiSQL Version: 12.12.0.7122
-- --------------------------------------------------------
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET NAMES utf8 */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
-- Dumping database structure for ranger_unit_tracker
CREATE DATABASE IF NOT EXISTS `ranger_unit_tracker` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */;
USE `ranger_unit_tracker`;
-- Dumping structure for table ranger_unit_tracker.applications
CREATE TABLE IF NOT EXISTS `applications` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) NOT NULL,
`app_version` int(11) NOT NULL,
`app_data` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`app_data`)),
`submitted_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT NULL,
`approved_at` datetime DEFAULT NULL,
`denied_at` datetime DEFAULT NULL,
`app_status` varchar(20) GENERATED ALWAYS AS (case when `approved_at` is not null then 'Accepted' when `denied_at` is not null then 'Denied' else 'Pending' end) STORED,
`decision_at` datetime GENERATED ALWAYS AS (coalesce(`approved_at`,`denied_at`)) STORED,
PRIMARY KEY (`id`),
UNIQUE KEY `member_id` (`member_id`),
CONSTRAINT `fk_app_member` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE,
CONSTRAINT `chk_json_valid` CHECK (json_valid(`app_data`)),
CONSTRAINT `chk_app_version` CHECK (`app_version` >= 1),
CONSTRAINT `chk_exclusive_decision` CHECK (`approved_at` is null or `denied_at` is null)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.application_comments
CREATE TABLE IF NOT EXISTS `application_comments` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`post_content` text COLLATE utf8mb4_unicode_ci NOT NULL,
`application_id` int(11) NOT NULL,
`poster_id` int(11) NOT NULL,
`post_time` timestamp NOT NULL DEFAULT current_timestamp(),
`last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
KEY `poster_id` (`poster_id`),
KEY `application_id` (`application_id`),
CONSTRAINT `application_comments_ibfk_1` FOREIGN KEY (`application_id`) REFERENCES `applications` (`id`),
CONSTRAINT `application_comments_ibfk_2` FOREIGN KEY (`poster_id`) REFERENCES `members` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.arma_maps
CREATE TABLE IF NOT EXISTS `arma_maps` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`map` varchar(50) DEFAULT NULL,
`name` text DEFAULT NULL,
`last_used` datetime DEFAULT NULL,
`deleted` tinyint(4) DEFAULT 0,
`workshop_id` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `map` (`map`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='Contains a list of Arma3 Maps';
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.awards
CREATE TABLE IF NOT EXISTS `awards` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` longtext DEFAULT NULL,
`short_name` longtext DEFAULT NULL,
`description` longtext DEFAULT NULL,
`type` longtext DEFAULT NULL,
`footprint` longtext DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`image_url` longtext DEFAULT NULL,
`deleted` tinyint(1) NOT NULL DEFAULT 0,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_awards_deleted` (`deleted`)
) ENGINE=InnoDB AUTO_INCREMENT=81 DEFAULT CHARSET=utf8mb4 COMMENT='Contains a list of Awards for the unit.';
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.calendar_events
CREATE TABLE IF NOT EXISTS `calendar_events` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`start` datetime NOT NULL,
`end` datetime NOT NULL,
`location` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`color` char(7) COLLATE utf8mb4_unicode_ci NOT NULL,
`description` text COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`creator` int(11) DEFAULT NULL,
`cancelled` tinyint(1) DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `fk_creator` (`creator`),
CONSTRAINT `fk_creator` FOREIGN KEY (`creator`) REFERENCES `members` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.calendar_events_signups
CREATE TABLE IF NOT EXISTS `calendar_events_signups` (
`member_id` int(11) NOT NULL,
`event_id` int(11) NOT NULL,
`status` enum('not_attending','attending','maybe') COLLATE utf8mb4_unicode_ci NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`member_id`,`event_id`),
KEY `fk_signup_event` (`event_id`),
CONSTRAINT `fk_signup_event` FOREIGN KEY (`event_id`) REFERENCES `calendar_events` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_signup_member` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.courses
CREATE TABLE IF NOT EXISTS `courses` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`short_name` varchar(10) NOT NULL,
`category` varchar(100) NOT NULL,
`description` varchar(1000) DEFAULT NULL,
`image_url` varchar(255) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT 0,
`prereq_id` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`),
UNIQUE KEY `shortName` (`short_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=39 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.courses_single
CREATE TABLE IF NOT EXISTS `courses_single` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`short_name` varchar(10) NOT NULL,
`category` varchar(100) NOT NULL,
`description` varchar(1000) DEFAULT NULL,
`image_url` varchar(255) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT 0,
`prereq_id` int(11) DEFAULT NULL,
`require_qual` tinyint(4) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`),
UNIQUE KEY `shortName` (`short_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=39 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.courses_sme
CREATE TABLE IF NOT EXISTS `courses_sme` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`course_id` int(11) NOT NULL DEFAULT 0,
`member_id` int(11) NOT NULL DEFAULT 0,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `fk_course_sme_course_id` (`course_id`),
KEY `fk_course_sme_member_id` (`member_id`),
CONSTRAINT `fk_course_sme_course_id` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_course_sme_member_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.course_attendees
CREATE TABLE IF NOT EXISTS `course_attendees` (
`passed` tinyint(1) DEFAULT 0,
`attendee_id` int(11) NOT NULL,
`course_event_id` int(11) NOT NULL,
`attendee_role_id` int(11) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`attendee_id`,`course_event_id`) USING BTREE,
KEY `courseInstanceId` (`course_event_id`) USING BTREE,
KEY `fk_CourseInstancesMembers_CoureseAttendeeType_id` (`attendee_role_id`) USING BTREE,
CONSTRAINT `fk_course_event_member_coures_attendee_type_id` FOREIGN KEY (`attendee_role_id`) REFERENCES `course_attendee_roles` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_course_event_members_coures_event_id` FOREIGN KEY (`course_event_id`) REFERENCES `course_events` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_course_event_members_members_id` FOREIGN KEY (`attendee_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.course_attendee_roles
CREATE TABLE IF NOT EXISTS `course_attendee_roles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) DEFAULT NULL,
`description` text DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `type` (`name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COMMENT='Changed from course_attendee_type to event_attendee_type';
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.course_category
CREATE TABLE IF NOT EXISTS `course_category` (
`id` int(11) DEFAULT NULL,
`name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.course_events
CREATE TABLE IF NOT EXISTS `course_events` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`course_id` int(11) DEFAULT NULL,
`event_type` int(11) DEFAULT NULL,
`event_date` datetime NOT NULL,
`guilded_event_id` int(11) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT 0,
`report_url` varchar(2048) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_course_events_event_type_id` (`event_type`) USING BTREE,
KEY `courseId` (`course_id`) USING BTREE,
CONSTRAINT `fk_coures_events_course_id` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_course_events_event_type_id` FOREIGN KEY (`event_type`) REFERENCES `event_types` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.course_qualified_trainers
CREATE TABLE IF NOT EXISTS `course_qualified_trainers` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) DEFAULT NULL,
`course_id` int(11) DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`qualified` tinyint(4) DEFAULT NULL,
`instance_qualified_id` int(11) DEFAULT NULL,
`deleted` tinyint(4) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `memberId_courseId` (`member_id`,`course_id`) USING BTREE,
KEY `fk_coures_qualified_trainers_coures_id` (`course_id`) USING BTREE,
KEY `fk_CourseTrainers_CourseInstance_id` (`instance_qualified_id`) USING BTREE,
CONSTRAINT `fk_coures_qualified_trainers_coures_events_id` FOREIGN KEY (`instance_qualified_id`) REFERENCES `course_events` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_coures_qualified_trainers_coures_id` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_coures_qualified_trainers_mebers_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='Contains a linked group of qualified trainers';
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.event_types
CREATE TABLE IF NOT EXISTS `event_types` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`event_type` varchar(100) DEFAULT NULL,
`event_category` varchar(100) DEFAULT NULL,
`deleted` tinyint(4) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.guilded_events
CREATE TABLE IF NOT EXISTS `guilded_events` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`channel_id` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`event_id` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`event_type` int(11) DEFAULT NULL,
`event_date` datetime DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` int(11) DEFAULT NULL,
`url` varchar(2048) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `calendar_id_event_id` (`channel_id`,`event_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3267 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.leave_of_absences
CREATE TABLE IF NOT EXISTS `leave_of_absences` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) DEFAULT NULL,
`filed_date` datetime NOT NULL,
`start_date` datetime NOT NULL,
`end_date` datetime NOT NULL,
`reason` text DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `fk_leave_of_absesnse_members_id` (`member_id`) USING BTREE,
CONSTRAINT `fk_leave_of_absesnse_members_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.members
CREATE TABLE IF NOT EXISTS `members` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`timezone` varchar(5) DEFAULT NULL,
`email` varchar(100) DEFAULT NULL,
`website` varchar(240) DEFAULT NULL,
`guilded_id` varchar(10) DEFAULT NULL,
`steam_id_64` varchar(17) DEFAULT NULL,
`teamspeak_uid` varchar(32) DEFAULT NULL,
`steam_profile_name` varchar(32) DEFAULT NULL,
`discord_id` varchar(20) DEFAULT NULL,
`discord_username` varchar(32) DEFAULT NULL,
`aliases` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`aliases`)),
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT NULL,
`remarks` text DEFAULT NULL,
`authentik_sub` varchar(255) DEFAULT NULL,
`authentik_issuer` varchar(255) DEFAULT NULL,
`state` enum('guest','applicant','member','retired','banned','denied') NOT NULL DEFAULT 'guest',
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`),
UNIQUE KEY `steamId64` (`steam_id_64`) USING BTREE,
UNIQUE KEY `discordId` (`discord_id`) USING BTREE,
UNIQUE KEY `guilded_id` (`guilded_id`),
UNIQUE KEY `uk_authentik_identity` (`authentik_issuer`,`authentik_sub`)
) ENGINE=InnoDB AUTO_INCREMENT=191 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.members_awards
CREATE TABLE IF NOT EXISTS `members_awards` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) DEFAULT NULL,
`awards_id` int(11) DEFAULT NULL,
`event_date` datetime DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `fk_members_awards_membres_id` (`member_id`),
KEY `fk_members_awards_awards_id` (`awards_id`),
CONSTRAINT `fk_members_awards_awards_id` FOREIGN KEY (`awards_id`) REFERENCES `awards` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_members_awards_membres_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.members_qualifications
CREATE TABLE IF NOT EXISTS `members_qualifications` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) DEFAULT 0,
`qualification_id` int(11) DEFAULT 0,
`event_date` datetime DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_members_qualifications_member_id` (`member_id`),
KEY `fk_members_qualifications_qualifications_id` (`qualification_id`),
CONSTRAINT `fk_members_qualifications_member_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_members_qualifications_qualifications_id` FOREIGN KEY (`qualification_id`) REFERENCES `qualifications` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.members_ranks
CREATE TABLE IF NOT EXISTS `members_ranks` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) DEFAULT NULL,
`rank_id` int(11) DEFAULT NULL,
`event_date` datetime DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `fk_members_ranks_members_id` (`member_id`),
KEY `fk_members_ranks_rank_id` (`rank_id`),
CONSTRAINT `fk_members_ranks_members_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_members_ranks_rank_id` FOREIGN KEY (`rank_id`) REFERENCES `ranks` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=210 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.members_roles
CREATE TABLE IF NOT EXISTS `members_roles` (
`member_id` int(11) NOT NULL,
`role_id` int(11) NOT NULL,
PRIMARY KEY (`member_id`,`role_id`),
KEY `role_id` (`role_id`),
CONSTRAINT `members_roles_ibfk_1` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON DELETE CASCADE,
CONSTRAINT `members_roles_ibfk_2` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.members_statuses
CREATE TABLE IF NOT EXISTS `members_statuses` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) DEFAULT NULL,
`status_id` int(11) DEFAULT NULL,
`reason_id` int(11) DEFAULT NULL,
`event_date` datetime DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `fk_members_statuses_status_id` (`status_id`),
KEY `fk_members_statuses_member_id` (`member_id`),
KEY `fk_members_statuses_statuses_reasons_id` (`reason_id`) USING BTREE,
CONSTRAINT `fk_members_statuses_member_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_members_statuses_status_id` FOREIGN KEY (`status_id`) REFERENCES `statuses` (`id`) ON UPDATE CASCADE,
CONSTRAINT `members_statuses_FK` FOREIGN KEY (`reason_id`) REFERENCES `statuses_reasons` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=207 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.mission_attendee_roles
CREATE TABLE IF NOT EXISTS `mission_attendee_roles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) DEFAULT NULL,
`short_name` varchar(50) DEFAULT NULL,
`description` text DEFAULT NULL,
`deleted` tinyint(4) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `role_name` (`name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.mission_events
CREATE TABLE IF NOT EXISTS `mission_events` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`event_type_id` int(11) NOT NULL,
`event_name` varchar(100) NOT NULL,
`description` varchar(100) DEFAULT NULL,
`mission_name` varchar(100) NOT NULL,
`author_id` int(11) DEFAULT NULL,
`map_id` int(11) DEFAULT NULL,
`event_date` datetime NOT NULL,
`guilded_event_id` int(11) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `event_name` (`event_name`),
KEY `fk_events_author_member_id` (`author_id`) USING BTREE,
KEY `fk_mission_event_type_id` (`event_type_id`),
KEY `fk_mission_event_map_id` (`map_id`),
CONSTRAINT `fk_events_author_id` FOREIGN KEY (`author_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_mission_event_map_id` FOREIGN KEY (`map_id`) REFERENCES `arma_maps` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_mission_event_type_id` FOREIGN KEY (`event_type_id`) REFERENCES `event_types` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.mission_event_attendees
CREATE TABLE IF NOT EXISTS `mission_event_attendees` (
`id` int(11) DEFAULT NULL,
`member_id` int(11) NOT NULL,
`event_id` int(11) NOT NULL,
`member_role_id` int(11) DEFAULT NULL,
`event_type` int(11) NOT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`member_id`,`event_id`) USING BTREE,
KEY `fk_mission_event_attendees_role_id` (`member_role_id`),
KEY `fk_mission_event_events_id` (`event_id`),
CONSTRAINT `fk_member_event_member_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_mission_event_attendees_role_id` FOREIGN KEY (`member_role_id`) REFERENCES `mission_attendee_roles` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_mission_event_events_id` FOREIGN KEY (`event_id`) REFERENCES `mission_events` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.qualifications
CREATE TABLE IF NOT EXISTS `qualifications` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) DEFAULT NULL,
`short_name` varchar(10) DEFAULT NULL,
`description` text DEFAULT NULL,
`type` varchar(100) DEFAULT NULL,
`classification` varchar(100) DEFAULT NULL,
`footprint` varchar(50) DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`image_url` varchar(250) DEFAULT NULL,
`deleted` tinytext DEFAULT '0',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `name` (`name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=99 DEFAULT CHARSET=utf8mb4 COMMENT='Contains a list of Member Qualifications for the unit.';
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.ranks
CREATE TABLE IF NOT EXISTS `ranks` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` longtext DEFAULT NULL,
`short_name` varchar(70) NOT NULL,
`category` varchar(100) NOT NULL,
`sort_id` int(11) NOT NULL DEFAULT 0,
`image_url` varchar(240) DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted` tinyint(1) NOT NULL DEFAULT 0,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `shortName` (`short_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=22 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.roles
CREATE TABLE IF NOT EXISTS `roles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`color` varchar(9) COLLATE utf8mb4_unicode_ci NOT NULL,
`description` text COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.statuses
CREATE TABLE IF NOT EXISTS `statuses` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.statuses_reasons
CREATE TABLE IF NOT EXISTS `statuses_reasons` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Contains a list of Reasons that a Status Change occured. This helps determine Promotions, Demotions, Transfers, Joins, Leaves.';
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.status_change_requests
CREATE TABLE IF NOT EXISTS `status_change_requests` (
`id` int(11) NOT NULL,
`member_id` int(11) DEFAULT NULL,
`current_status` int(11) DEFAULT NULL,
`next_status` int(11) DEFAULT NULL,
`next_rank` int(11) DEFAULT NULL,
`approval_from` int(11) DEFAULT NULL,
`approval_from_date` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`approval_to` int(11) DEFAULT NULL,
`approval_to_date` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
`request_date` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `member_id` (`member_id`),
KEY `next_status` (`next_status`),
KEY `next_rank` (`next_rank`),
KEY `approval_from` (`approval_from`),
KEY `approval_to` (`approval_to`),
KEY `current_status` (`current_status`),
CONSTRAINT `status_change_requests_ibfk_1` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`),
CONSTRAINT `status_change_requests_ibfk_2` FOREIGN KEY (`next_status`) REFERENCES `statuses` (`id`),
CONSTRAINT `status_change_requests_ibfk_3` FOREIGN KEY (`next_rank`) REFERENCES `ranks` (`id`),
CONSTRAINT `status_change_requests_ibfk_4` FOREIGN KEY (`approval_from`) REFERENCES `members` (`id`),
CONSTRAINT `status_change_requests_ibfk_5` FOREIGN KEY (`approval_to`) REFERENCES `members` (`id`),
CONSTRAINT `status_change_requests_ibfk_6` FOREIGN KEY (`current_status`) REFERENCES `statuses` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for view ranger_unit_tracker.view_member_rank_status_all
-- Creating temporary table to overcome VIEW dependency errors
CREATE TABLE `view_member_rank_status_all` (
`member_id` INT(11) NOT NULL,
`member_name` VARCHAR(1) NOT NULL COLLATE 'utf8mb4_general_ci',
`rank` LONGTEXT NULL COLLATE 'utf8mb4_general_ci',
`rank_date` DATETIME NULL,
`status` VARCHAR(1) NULL COLLATE 'utf8mb4_general_ci',
`status_date` DATETIME NULL
);
-- Removing temporary table and create final VIEW structure
DROP TABLE IF EXISTS `view_member_rank_status_all`;
CREATE ALGORITHM=UNDEFINED SQL SECURITY DEFINER VIEW `view_member_rank_status_all` AS select `ranger_unit_tracker`.`members`.`id` AS `member_id`,`ranger_unit_tracker`.`members`.`name` AS `member_name`,`ranger_unit_tracker`.`ranks`.`name` AS `rank`,`members_ranks`.`event_date` AS `rank_date`,`ranger_unit_tracker`.`statuses`.`name` AS `status`,`members_statuses`.`event_date` AS `status_date` from ((((`ranger_unit_tracker`.`members` left join (select `mr`.`id` AS `id`,`mr`.`member_id` AS `member_id`,`mr`.`rank_id` AS `rank_id`,`mr`.`event_date` AS `event_date`,`mr`.`created_at` AS `created_at`,`mr`.`updated_at` AS `updated_at` from (`ranger_unit_tracker`.`members_ranks` `mr` join (select `ranger_unit_tracker`.`members_ranks`.`member_id` AS `member_id`,max(`ranger_unit_tracker`.`members_ranks`.`event_date`) AS `max_rank_date` from `ranger_unit_tracker`.`members_ranks` group by `ranger_unit_tracker`.`members_ranks`.`member_id`) `latest_ranks` on(`mr`.`member_id` = `latest_ranks`.`member_id` and `mr`.`event_date` = `latest_ranks`.`max_rank_date`))) `members_ranks` on(`ranger_unit_tracker`.`members`.`id` = `members_ranks`.`member_id`)) left join (select `ms`.`id` AS `id`,`ms`.`member_id` AS `member_id`,`ms`.`status_id` AS `status_id`,`ms`.`event_date` AS `event_date`,`ms`.`created_at` AS `created_at`,`ms`.`updated_at` AS `updated_at` from (`ranger_unit_tracker`.`members_statuses` `ms` join (select `ranger_unit_tracker`.`members_statuses`.`member_id` AS `member_id`,max(`ranger_unit_tracker`.`members_statuses`.`event_date`) AS `max_status_date` from `ranger_unit_tracker`.`members_statuses` group by `ranger_unit_tracker`.`members_statuses`.`member_id`) `latest_statuses` on(`ms`.`member_id` = `latest_statuses`.`member_id` and `ms`.`event_date` = `latest_statuses`.`max_status_date`))) `members_statuses` on(`ranger_unit_tracker`.`members`.`id` = `members_statuses`.`member_id`)) left join `ranger_unit_tracker`.`ranks` on(`members_ranks`.`rank_id` = `ranger_unit_tracker`.`ranks`.`id`)) left join `ranger_unit_tracker`.`statuses` on(`members_statuses`.`status_id` = `ranger_unit_tracker`.`statuses`.`id`))
;
/*!40103 SET TIME_ZONE=IFNULL(@OLD_TIME_ZONE, 'system') */;
/*!40101 SET SQL_MODE=IFNULL(@OLD_SQL_MODE, '') */;
/*!40014 SET FOREIGN_KEY_CHECKS=IFNULL(@OLD_FOREIGN_KEY_CHECKS, 1) */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40111 SET SQL_NOTES=IFNULL(@OLD_SQL_NOTES, 1) */;

3655
api/migrations/seed.sql Normal file

File diff suppressed because it is too large Load Diff

1529
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,14 +8,20 @@
"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 && tsc-alias && node ./built/api/src/index.js", "dev": "tsc && cross-env NODE_ENV=development node ./built/api/src/index.js",
"build": "tsc && tsc-alias" "prod": "tsc && node ./built/api/src/index.js",
"migrate": "node ./scripts/migrate.js",
"migrate:create": "npm run migrate -- create -ext sql -dir /migrations",
"migrate:seed": "node ./scripts/seed.js",
"migrate:up": "npm run migrate -- up",
"migrate:down": "npm run migrate -- down 1"
}, },
"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": "16.6.1",
"express": "^5.1.0", "express": "^5.1.0",
"express-session": "^1.18.2", "express-session": "^1.18.2",
"mariadb": "^3.4.5", "mariadb": "^3.4.5",
@@ -28,7 +34,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", "cross-env": "^10.1.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

30
api/scripts/migrate.js Normal file
View File

@@ -0,0 +1,30 @@
const dotenv = require('dotenv');
const path = require('path');
const { execSync } = require('child_process');
const mode = process.env.NODE_ENV || "development";
dotenv.config({ path: path.resolve(process.cwd(), `.env.${mode}`) });
const db = {
user: process.env.DB_USERNAME,
pass: process.env.DB_PASSWORD,
host: process.env.DB_HOST,
port: process.env.DB_PORT,
name: process.env.DB_DATABASE,
};
const dbUrl = `mysql://${db.user}:${db.pass}@tcp(host.docker.internal:${db.port})/${db.name}`;
const args = process.argv.slice(2).join(" ");
const migrations = path.join(process.cwd(), "migrations");
const cmd = [
"docker run --rm",
`-v ${migrations}:/migrations`,
"migrate/migrate",
`-path=/migrations`,
`-database \"${dbUrl}\"`,
args,
].join(" ");
console.log(cmd);
execSync(cmd, { stdio: "inherit" });

33
api/scripts/seed.js Normal file
View File

@@ -0,0 +1,33 @@
const dotenv = require("dotenv");
const path = require("path");
const mariadb = require("mariadb");
const fs = require("fs");
const mode = process.env.NODE_ENV || "development";
dotenv.config({ path: path.resolve(process.cwd(), `.env.${mode}`) });
const { DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME } = process.env;
//do not accidentally seed prod pls
if (mode !== "development") {
process.exit(0);
}
(async () => {
const conn = await mariadb.createConnection({
host: DB_HOST,
port: DB_PORT,
user: DB_USERNAME,
password: DB_PASSWORD,
database: DB_NAME,
multipleStatements: true,
});
const seedFile = path.join(process.cwd(), "migrations", "seed.sql");
const sql = fs.readFileSync(seedFile, "utf8");
await conn.query(sql);
await conn.end();
console.log("Seeded");
})();

View File

@@ -1,8 +1,14 @@
// const mariadb = require('mariadb') // const mariadb = require('mariadb')
import * as mariadb from 'mariadb'; import * as mariadb from 'mariadb';
const dotenv = require('dotenv') // const dotenv = require('dotenv')
dotenv.config(); // import path = require('path');
// console.log('NODE_ENV =', process.env.NODE_ENV);
// const envFile = process.env.NODE_ENV === 'development' ? '.env.development' : '.env';
// dotenv.config({ path: path.resolve(process.cwd(), envFile) });
// console.log(`Loaded environment from ${envFile}`);
// console.log(process.env.DB_HOST)
const pool = mariadb.createPool({ const pool = mariadb.createPool({
host: process.env.DB_HOST, host: process.env.DB_HOST,

View File

@@ -1,5 +1,8 @@
const dotenv = require('dotenv') const dotenv = require('dotenv')
dotenv.config(); const path = require('path')
const envFile = process.env.NODE_ENV === 'development' ? '.env.development' : '.env';
dotenv.config({ path: path.resolve(process.cwd(), envFile) });
console.log(`Loaded environment from ${envFile}`);
const express = require('express') const express = require('express')
const cors = require('cors') const cors = require('cors')
@@ -8,7 +11,7 @@ const app = express()
app.use(morgan('dev')) app.use(morgan('dev'))
app.use(cors({ app.use(cors({
origin: [process.env.CLIENT_URL], // your SPA origins origin: ['https://aj17thdev.nexuszone.net', 'http://localhost:5173'], // your SPA origins
credentials: true credentials: true
})); }));
@@ -18,19 +21,6 @@ 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')
@@ -45,7 +35,7 @@ app.use(session({
cookie: { cookie: {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
domain: process.env.CLIENT_DOMAIN domain: 'nexuszone.net'
} }
})); }));
app.use(passport.authenticate('session')); app.use(passport.authenticate('session'));
@@ -58,10 +48,7 @@ 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);
@@ -72,9 +59,6 @@ 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,31 +2,11 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
import pool from '../db'; import pool from '../db';
import { approveApplication, createApplication, denyApplication, getAllMemberApplications, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService'; import { approveApplication, createApplication, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService';
import { MemberState, setUserState } from '../services/memberService'; import { MemberState, setUserState } from '../services/memberService';
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) => {
@@ -36,13 +16,13 @@ router.post('/', async (req, res) => {
const appVersion = 1; const appVersion = 1;
await createApplication(memberID, appVersion, JSON.stringify(App)) createApplication(memberID, appVersion, JSON.stringify(App))
await setUserState(memberID, MemberState.Applicant); setUserState(memberID, MemberState.Applicant);
res.sendStatus(201); res.sendStatus(201);
} catch (err) { } catch (err) {
console.error('Failed to create application: \n', err); console.error('Insert failed:', err);
res.status(500).json({ error: 'Failed to create application' }); res.status(500).json({ error: 'Failed to save application' });
} }
}); });
@@ -57,56 +37,23 @@ router.get('/all', async (req, res) => {
} }
}); });
router.get('/meList', async (req, res) => {
let userID = req.user.id;
try {
let application = await getAllMemberApplications(userID);
return res.status(200).json(application);
} catch (error) {
console.error('Failed to load applications: \n', error);
return res.status(500).json(error);
}
})
router.get('/me', async (req, res) => { router.get('/me', async (req, res) => {
let userID = req.user.id; let userID = req.user.id;
try { console.log("application/me")
let application = await getMemberApplication(userID);
if (application === undefined) let app = getMemberApplication(userID);
res.sendStatus(204); console.log(app);
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('/me/:id', async (req: Request, res: Response) => { router.get('/:id', async (req, res) => {
let appID = Number(req.params.id); let appID = req.params.id;
let member = req.user.id; console.log("HELLO")
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);
@@ -122,64 +69,30 @@ router.get('/me/:id', async (req: Request, res: Response) => {
} }
}); });
// 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: Request, res: Response) => { router.post('/approve/:id', async (req, res) => {
const appID = Number(req.params.id); const appID = 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);
await pool.query('CALL sp_accept_new_recruit_validation(?, ?, ?, ?)', [Number(process.env.CONFIG_ID), app.member_id, approved_by, approved_by]) let nextRank = await getRankByName('Recruit')
// let nextRank = await getRankByName('Recruit') await insertMemberRank(app.member_id, nextRank.id);
// await insertMemberRank(app.member_id, nextRank.id); //assign user to "pending basic"
// //assign user to "pending basic" await assignUserToStatus(app.member_id, 1);
// 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);
@@ -191,11 +104,26 @@ router.post('/approve/:id', async (req: Request, res: Response) => {
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 app = await getApplicationByID(appID); const result = await pool.execute(sql, appID);
await denyApplication(appID);
await setUserState(app.member_id, MemberState.Denied); console.log(result);
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' });
@@ -203,12 +131,10 @@ router.post('/deny/:id', async (req, res) => {
}); });
// POST /application/:id/comment // POST /application/:id/comment
router.post('/:id/comment', async (req: Request, res: Response) => { router.post('/:id/comment', async (req, res) => {
const appID = req.params.id; const appID = req.params.id;
const data = req.body.message; const data = req.body.message;
const user = req.user; const user = 1;
console.log(user)
const sql = `INSERT INTO application_comments( const sql = `INSERT INTO application_comments(
application_id, application_id,
@@ -220,7 +146,7 @@ VALUES(?, ?, ?);`
try { try {
const conn = await pool.getConnection(); const conn = await pool.getConnection();
const result = await conn.query(sql, [appID, user.id, data]) const result = await conn.query(sql, [appID, user, data])
console.log(result) console.log(result)
if (result.affectedRows !== 1) { if (result.affectedRows !== 1) {
conn.release(); conn.release();
@@ -245,61 +171,4 @@ 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,22 +12,21 @@ const querystring = require('querystring');
passport.use(new OpenIDConnectStrategy({ passport.use(new OpenIDConnectStrategy({
issuer: process.env.AUTH_ISSUER, issuer: process.env.AUTH_ISSUER,
authorizationURL: process.env.AUTH_DOMAIN + '/authorize/', authorizationURL: 'https://sso.iceberg-gaming.com/application/o/authorize/',
tokenURL: process.env.AUTH_DOMAIN + '/token/', tokenURL: 'https://sso.iceberg-gaming.com/application/o/token/',
userInfoURL: process.env.AUTH_DOMAIN + '/userinfo/', userInfoURL: 'https://sso.iceberg-gaming.com/application/o/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('profile:', profile); // console.log('id_token claims:', JSON.stringify(jwtClaims, null, 2));
console.log('id_token claims:', JSON.stringify(jwtClaims, null, 2)); // console.log('preferred_username:', jwtClaims?.preferred_username);
console.log('preferred_username:', jwtClaims?.preferred_username);
const con = await pool.getConnection(); const con = await pool.getConnection();
try { try {
@@ -47,8 +46,9 @@ 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 = Number(result.insertId); memberId = 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: process.env.CLIENT_URL // failureRedirect: 'https://aj17thdev.nexuszone.net/'
// }) // })
// }); // });
@@ -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(process.env.CLIENT_URL); if (!user) return res.redirect('https://aj17thdev.nexuszone.net/');
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 || process.env.CLIENT_URL; const redirectTo = redirectURI || 'https://aj17thdev.nexuszone.net/';
delete req.session.redirectTo; delete req.session.redirectTo;
return res.redirect(redirectTo); return res.redirect(redirectTo);
}); });
})(req, res, next); })(req, res, next);
}); });
router.get('/logout', function (req, res, next) { router.post('/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: process.env.CLIENT_URL returnTo: 'https://aj17thdev.nexuszone.net/'
}; };
res.redirect(process.env.AUTH_END_SESSION_URI + '?' + querystring.stringify(params)); res.redirect(process.env.AUTH_DOMAIN + '/v2/logout?' + querystring.stringify(params));
}); });
}); });
@@ -109,7 +109,6 @@ 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,6 +1,4 @@
import { Request, Response } from "express"; import { getEventAttendance, getEventDetails, getShortEventsInRange } from "../services/calendarService";
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();
@@ -11,108 +9,42 @@ function addMonths(date: Date, months: number): Date {
return d return d
} }
//get calendar events paged, requires a query string with from= and to= as mariadb ISO strings //get calendar events paged
r.get('/', async (req, res) => { r.get('/', async (req, res) => {
try { const viewDate: Date = req.body.date;
const fromDate: string = req.query.from; //generate date range
const toDate: string = req.query.to; const backDate: Date = addMonths(viewDate, -1);
const frontDate: Date = addMonths(viewDate, 2);
if (fromDate === undefined || toDate === undefined) { const events = getShortEventsInRange(backDate, frontDate);
res.status(400).send("Missing required query parameters 'from' and 'to'");
return;
}
const events = await getShortEventsInRange(fromDate, toDate); res.status(200).json(events);
res.status(200).json(events);
} catch (error) {
console.error('Error fetching calendar events:', error);
res.status(500).send('Error fetching calendar events');
}
}); });
r.get('/upcoming', async (req, res) => { r.get('/upcoming', async (req, res) => {
res.sendStatus(501); res.sendStatus(501);
}) })
r.post('/:id/cancel', async (req: Request, res: Response) => {
try {
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');
}
})
r.post('/:id/attendance', async (req: Request, res: Response) => {
try {
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 //get event details
r.get('/:id', async (req: Request, res: Response) => { r.get('/:id', async (req, res) => {
try { try {
const eventID: number = Number(req.params.id); const eventID: number = req.params.id;
let details: CalendarEvent = await getEventDetails(eventID); let details = getEventDetails(eventID);
details.eventSignups = await getEventAttendance(eventID); let attendance = await getEventAttendance(eventID);
res.status(200).json(details);
let out = { ...details, attendance }
console.log(out);
res.status(200).json(out);
} catch (err) { } catch (err) {
console.error('Insert failed:', err); console.error('Insert failed:', err);
res.status(500).json(err); res.status(500).json(err);
} }
}) })
//post a new calendar event //post a new calendar event
r.post('/', async (req: Request, res: Response) => { r.post('/', async (req, res) => {
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);
}
}) })
r.put('/', async (req: Request, res: Response) => { module.exports.calendar = r;
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;

View File

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

56
api/src/routes/loa.js Normal file
View File

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

View File

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

@@ -2,7 +2,6 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
import pool from '../db'; import pool from '../db';
import { getUserActiveLOA } from '../services/loaService';
import { getUserData } from '../services/memberService'; import { getUserData } from '../services/memberService';
import { getUserRoles } from '../services/rolesService'; import { getUserRoles } from '../services/rolesService';
@@ -27,7 +26,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_unit_status_latest v;`); FROM view_member_rank_status_all 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);
@@ -41,13 +40,12 @@ 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);
@@ -63,7 +61,7 @@ router.get('/me', async (req, res) => {
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_unit_status_latest WHERE id = $1;', [userId]); const result = await pool.query('SELECT * FROM view_member_rank_status_all 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' });
} }

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_unit_status_latest v ON mr.member_id = v.member_id JOIN view_member_rank_status_all v ON mr.member_id = v.member_id
`); `);

View File

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

View File

@@ -1,6 +1,5 @@
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 (?, ?, ?);`;
@@ -13,13 +12,12 @@ 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 = ? ORDER BY submitted_at DESC LIMIT 1;`; WHERE app.member_id = ?;`;
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.*,
@@ -46,20 +44,7 @@ export async function getApplicationList(): Promise<ApplicationListRow[]> {
return rows; return rows;
} }
export async function getAllMemberApplications(memberID: number): Promise<ApplicationListRow[]> { export async function approveApplication(id) {
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()
@@ -72,38 +57,15 @@ export async function approveApplication(id: number) {
return result; return result;
} }
export async function denyApplication(id: number) { export async function getApplicationComments(appID: number): Promise<CommentRow[]> {
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
${whereClause}`, WHERE app.application_id = ?;`,
[appID]); [appID]);
} }

6
api/src/services/calendarService.d.ts vendored Normal file
View File

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

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

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

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

View File

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

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

View File

@@ -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, start_date) VALUES (?, ?, ?);` ? `INSERT INTO members_ranks (member_id, rank_id, event_date) VALUES (?, ?, ?);`
: `INSERT INTO members_ranks (member_id, rank_id, start_date) VALUES (?, ?, NOW());`; : `INSERT INTO members_ranks (member_id, rank_id, event_date) VALUES (?, ?, NOW());`;
const params = date const params = date
? [member_id, rank_id, date] ? [member_id, rank_id, date]

View File

@@ -1,5 +1,4 @@
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) {
@@ -17,7 +16,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): Promise<Role[]> { export async function getUserRoles(userID: number) {
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, start_date) VALUES (?, ?, NOW())` const sql = `INSERT INTO members_statuses (member_id, status_id, event_date) VALUES (?, ?, NOW())`
await pool.execute(sql, [userID, statusID]); await pool.execute(sql, [userID, statusID]);
} }

13
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,13 @@
version: "3.9"
services:
db:
image: mariadb:10.6.23-ubi9
environment:
MARIADB_ROOT_PASSWORD: root
MARIADB_DATABASE: ranger_unit_tracker
MARIADB_USER: dev
MARIADB_PASSWORD: dev
ports:
- "3306:3306"
volumes:
- ./db_data:/var/lib/mysql

View File

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

View File

@@ -1,24 +0,0 @@
{
"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,8 +2,5 @@
"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

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

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

@@ -1,60 +0,0 @@
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.",
});
}
})
})

View File

@@ -1,17 +0,0 @@
{
"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,7 +40,6 @@ export interface CommentRow {
post_time: string; post_time: string;
last_modified: string | null; last_modified: string | null;
poster_name: string; poster_name: string;
admin_only: boolean;
} }
export interface ApplicationFull { export interface ApplicationFull {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

106
ui/package-lock.json generated
View File

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

View File

@@ -17,7 +17,6 @@
"@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",
@@ -26,7 +25,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.6.1", "reka-ui": "^2.5.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -1,14 +1,39 @@
<script setup> <script setup>
import { RouterView } from 'vue-router'; import { RouterLink, 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", {
@@ -17,29 +42,88 @@ function formatDate(dateStr) {
day: "numeric", day: "numeric",
}); });
} }
const environment = import.meta.env.VITE_ENVIRONMENT;
</script> </script>
<template> <template>
<div class="flex flex-col min-h-screen"> <div>
<div class="sticky top-0 bg-background z-50"> <div class="flex items-center justify-between px-10">
<Navbar class="flex"></Navbar> <div></div>
<Alert v-if="environment == 'dev'" class="m-2 mx-auto w-5xl" variant="info"> <div class="h-15 flex items-center justify-center gap-20">
<AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto"> <RouterLink to="/">
<p>This is a development build of the application. Some features will be unavailable or unstable.</p> <Button variant="link">Home</Button>
</AlertDescription> </RouterLink>
</Alert> <RouterLink to="/calendar">
<Alert v-if="userStore.user?.LOAData?.[0]" class="m-2 mx-auto w-5xl" variant="info"> <Button variant="link">Calendar</Button>
<AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto"> </RouterLink>
<p>You are on LOA until <strong>{{ formatDate(userStore.user?.LOAData?.[0].end_date) }}</strong></p> <RouterLink to="/members">
<Button variant="secondary" @click="async () => { await cancelLOA(userStore.user?.LOAData?.[0].id); userStore.loadUser(); }">End <Button variant="link">Members</Button>
LOA</Button> </RouterLink>
</AlertDescription> <Popover>
</Alert> <PopoverTrigger as-child>
</div> <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>
<RouterView class="flex-1 min-h-0"></RouterView> </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>
<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>
</div> </div>
</template> </template>

View File

@@ -1,11 +1,80 @@
import { ApplicationFull } from "@shared/types/application"; export type ApplicationDto = Partial<{
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, asAdmin: boolean = false): Promise<ApplicationFull | null> { export async function loadApplication(id: number | string): Promise<ApplicationFull | null> {
const res = await fetch(`${addr}/application/${id}?admin=${asAdmin}`, { credentials: 'include' }) const res = await fetch(`${addr}/application/${id}`)
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()
@@ -35,22 +104,6 @@ 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),
}) })
@@ -68,28 +121,8 @@ 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', credentials: 'include' }) const res = await fetch(`${addr}/application/approve/${id}`, { method: 'POST' })
if (!res.ok) { if (!res.ok) {
console.error("Something went wrong approving the application") console.error("Something went wrong approving the application")
@@ -97,36 +130,9 @@ 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', credentials: 'include' }) const res = await fetch(`${addr}/application/deny/${id}`, { method: 'POST' })
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,107 +21,22 @@ export interface CalendarSignup {
state: CalendarAttendance state: CalendarAttendance
} }
import { CalendarEventShort, CalendarEvent } from "@shared/types/calendar";
//@ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getMonthCalendarEvents(viewedMonth: Date): Promise<CalendarEventShort[]> {
const year = viewedMonth.getFullYear();
const month = viewedMonth.getMonth();
// Base range: first and last day of the month
const firstOfMonth = new Date(year, month, 1);
const lastOfMonth = new Date(year, month + 1, 0);
// --- Apply 10 day padding ---
const start = new Date(firstOfMonth);
start.setDate(start.getDate() - 10);
const end = new Date(lastOfMonth);
end.setDate(end.getDate() + 10);
end.setHours(23, 59, 59, 999);
const from = start.toISOString();
const to = end.toISOString();
const url = `${addr}/calendar?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch events: ${res.status} ${res.statusText}`);
}
return res.json();
}
export async function getCalendarEvent(id: number): Promise<CalendarEvent> {
let res = await fetch(`${addr}/calendar/${id}`);
if (res.ok) {
return await res.json();
} else {
throw new Error(`Failed to fetch event: ${res.status} ${res.statusText}`);
}
}
export async function createCalendarEvent(eventData: CalendarEvent) { export async function createCalendarEvent(eventData: CalendarEvent) {
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 setCancelCalendarEvent(eventID: number, cancel: boolean) { export async function cancelCalendarEvent(eventID: number) {
let route = cancel ? "cancel" : "uncancel";
console.log(route); }
let res = await fetch(`${addr}/calendar/${eventID}/${route}`, {
method: "POST", export async function adminCancelCalendarEvent(eventID: number) {
credentials: "include"
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }
export async function setCalendarEventAttendance(eventID: number, state: CalendarAttendance) { export async function setCalendarEventAttendance(eventID: number, state: CalendarAttendance) {
let res = await fetch(`${addr}/calendar/${eventID}/attendance?state=${state}`, {
method: "POST",
credentials: "include",
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }

View File

@@ -1,4 +1,12 @@
import { LOARequest, LOAType } from '@shared/types/loa' export type LOARequest = {
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;
@@ -9,24 +17,6 @@ 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) {
@@ -36,7 +26,6 @@ export async function adminSubmitLOA(request: LOARequest): Promise<{ id?: number
} }
} }
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",
@@ -71,84 +60,3 @@ 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

@@ -3,8 +3,6 @@ export type Member = {
member_name: string; member_name: string;
rank: string | null; rank: string | null;
rank_date: string | null; rank_date: string | null;
unit: string | null;
unit_date: string | null;
status: string | null; status: string | null;
status_date: string | null; status_date: string | null;
on_loa: boolean | null; on_loa: boolean | null;

View File

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

@@ -4,55 +4,6 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: oklch(0.2046 0 0);
--foreground: oklch(0.9219 0 0);
--card: oklch(23.075% 0.00003 271.152);
--card-foreground: oklch(0.9219 0 0);
--popover: oklch(0.2686 0 0);
--popover-foreground: oklch(0.9219 0 0);
--primary: oklch(0.7686 0.1647 70.0804);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.2686 0 0);
--secondary-foreground: oklch(0.9219 0 0);
--muted: oklch(0.2686 0 0);
--muted-foreground: oklch(0.7155 0 0);
--accent: oklch(100% 0.00011 271.152 / 0.253);
--accent-foreground: oklch(100% 0.00011 271.152);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--success: oklch(66.104% 0.16937 144.153);
--success-foreground: oklch(1.0000 0 0);
--border: oklch(0.3715 0 0);
--input: oklch(0.3715 0 0);
--ring: oklch(0.7686 0.1647 70.0804);
--chart-1: oklch(0.8369 0.1644 84.4286);
--chart-2: oklch(0.6658 0.1574 58.3183);
--chart-3: oklch(0.4732 0.1247 46.2007);
--chart-4: oklch(0.5553 0.1455 48.9975);
--chart-5: oklch(0.4732 0.1247 46.2007);
--sidebar: oklch(0.1684 0 0);
--sidebar-foreground: oklch(0.9219 0 0);
--sidebar-primary: oklch(0.7686 0.1647 70.0804);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.4732 0.1247 46.2007);
--sidebar-accent-foreground: oklch(0.9243 0.1151 95.7459);
--sidebar-border: oklch(0.3715 0 0);
--sidebar-ring: oklch(0.7686 0.1647 70.0804);
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.375rem;
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 2px 4px -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-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35);
}
/* .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 +50,56 @@
--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);
} */ }
.dark {
--background: oklch(0.2046 0 0);
--foreground: oklch(0.9219 0 0);
--card: oklch(23.075% 0.00003 271.152);
--card-foreground: oklch(0.9219 0 0);
--popover: oklch(0.2686 0 0);
--popover-foreground: oklch(0.9219 0 0);
--primary: oklch(0.7686 0.1647 70.0804);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.2686 0 0);
--secondary-foreground: oklch(0.9219 0 0);
--muted: oklch(0.2686 0 0);
--muted-foreground: oklch(0.7155 0 0);
--accent: oklch(0.4732 0.1247 46.2007);
--accent-foreground: oklch(0.9243 0.1151 95.7459);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--success: oklch(66.104% 0.16937 144.153);
--success-foreground: oklch(1.0000 0 0);
--border: oklch(0.3715 0 0);
--input: oklch(0.3715 0 0);
--ring: oklch(0.7686 0.1647 70.0804);
--chart-1: oklch(0.8369 0.1644 84.4286);
--chart-2: oklch(0.6658 0.1574 58.3183);
--chart-3: oklch(0.4732 0.1247 46.2007);
--chart-4: oklch(0.5553 0.1455 48.9975);
--chart-5: oklch(0.4732 0.1247 46.2007);
--sidebar: oklch(0.1684 0 0);
--sidebar-foreground: oklch(0.9219 0 0);
--sidebar-primary: oklch(0.7686 0.1647 70.0804);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.4732 0.1247 46.2007);
--sidebar-accent-foreground: oklch(0.9243 0.1151 95.7459);
--sidebar-border: oklch(0.3715 0 0);
--sidebar-ring: oklch(0.7686 0.1647 70.0804);
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.375rem;
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 2px 4px -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-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35);
}
@theme inline { @theme inline {
@@ -166,111 +166,3 @@
@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

@@ -1,195 +0,0 @@
<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';
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>My Profile</DropdownMenuItem> -->
<!-- <DropdownMenuItem>Settings</DropdownMenuItem> -->
<DropdownMenuItem @click="$router.push('/join')">My Application</DropdownMenuItem>
<DropdownMenuItem @click="$router.push('/applications')">Application History</DropdownMenuItem>
<DropdownMenuItem :variant="'destructive'" @click="logout()">Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<a v-else :href="APIHOST + '/login'">Login</a>
</div>
</div>
<!-- <Separator></Separator> -->
</div>
</template>

View File

@@ -11,18 +11,13 @@ 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'
const props = defineProps<{ const props = defineProps<{
messages: CommentRow[] messages: Array<Record<string, any>>
}>() }>()
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(
@@ -31,14 +26,9 @@ 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 }) {
if (submitMode.value === "internal") emit('post', values.text.trim())
emit('postInternal', values.text.trim())
else
emit('post', values.text.trim())
resetForm() resetForm()
} }
</script> </script>
@@ -48,7 +38,7 @@ function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => vo
<Form :validation-schema="commentSchema" @submit="onSubmit"> <Form :validation-schema="commentSchema" @submit="onSubmit">
<FormField name="text" v-slot="{ componentField }"> <FormField name="text" v-slot="{ componentField }">
<FormItem> <FormItem>
<FormLabel>Comment</FormLabel> <FormLabel class="sr-only">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" />
@@ -58,31 +48,25 @@ 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 gap-2"> <div class="mt-2 flex justify-end">
<Button type="submit" @click="submitMode = 'internal'" variant="outline">Post (Internal)</Button> <Button type="submit">Post</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.comment_id ?? i" class="rounded-md border p-3 space-y-5" <div v-for="(message, i) in props.messages" :key="message.id ?? i"
:class="message.admin_only ? 'border-amber-300/70' : 'border-neutral-800'"> class="rounded-md border border-neutral-800 p-3 space-y-5">
<!-- Comment header --> <!-- Comment header -->
<div class="flex justify-between"> <div class="flex justify-between">
<div class="flex"> <p>{{ message.poster_name }}</p>
<p>{{ message.poster_name }}</p>
<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,38 +13,26 @@ 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 { nextTick, onMounted, ref, watch } from 'vue'; import { onMounted, ref } 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 '@shared/types/application'; import { ApplicationData } from '@/api/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().nonempty(), name: z.string(),
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().nonempty(), hobbies: z.string(),
military: z.boolean(), military: z.boolean(),
communities: z.string().nonempty(), communities: z.string(),
joinReason: z.string().nonempty(), joinReason: z.string(),
milsimAttraction: z.string().nonempty(), milsimAttraction: z.string(),
referral: z.string().nonempty(), referral: z.string(),
steamProfile: z.string().nonempty().refine((val) => regexA.test(val) || regexB.test(val), { message: "Invalid Steam profile URL." }), steamProfile: z.string(),
timezone: z.string().nonempty(), timezone: z.string(),
canAttendSaturday: z.boolean(), canAttendSaturday: z.boolean(),
interests: z.string().nonempty(), interests: z.string(),
acknowledgeRules: z.literal(true, { aknowledgeRules: z.literal(true, {
errorMap: () => ({ message: "Required" }) errorMap: () => ({ message: "Required" })
}), }),
})) }))
@@ -53,7 +41,7 @@ const formSchema = toTypedSchema(z.object({
const fallbackInitials = { const fallbackInitials = {
military: false, military: false,
canAttendSaturday: false, canAttendSaturday: false,
acknowledgeRules: false, aknowledgeRules: false,
} }
const props = defineProps<{ const props = defineProps<{
@@ -69,7 +57,7 @@ async function onSubmit(val: any) {
emit('submit', val); emit('submit', val);
} }
onMounted(async () => { onMounted(() => {
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)
@@ -79,35 +67,8 @@ onMounted(async () => {
} 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>
@@ -121,9 +82,7 @@ watch(() => showCoC.value, async () => {
<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>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -135,9 +94,7 @@ watch(() => showCoC.value, async () => {
<FormControl> <FormControl>
<Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -148,9 +105,7 @@ watch(() => showCoC.value, async () => {
<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>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -162,9 +117,7 @@ watch(() => showCoC.value, async () => {
<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>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -178,9 +131,7 @@ watch(() => showCoC.value, async () => {
<span>Yes (checked) / No (unchecked)</span> <span>Yes (checked) / No (unchecked)</span>
</div> </div>
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -191,9 +142,7 @@ watch(() => showCoC.value, async () => {
<FormControl> <FormControl>
<Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -205,9 +154,7 @@ watch(() => showCoC.value, async () => {
<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>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -219,9 +166,7 @@ watch(() => showCoC.value, async () => {
<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>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -233,9 +178,7 @@ watch(() => showCoC.value, async () => {
<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>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -251,9 +194,7 @@ watch(() => showCoC.value, async () => {
<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>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -265,9 +206,7 @@ watch(() => showCoC.value, async () => {
<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>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -281,9 +220,7 @@ watch(() => showCoC.value, async () => {
<span>Yes (checked) / No (unchecked)</span> <span>Yes (checked) / No (unchecked)</span>
</div> </div>
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -295,44 +232,27 @@ watch(() => showCoC.value, async () => {
<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>
<div class="h-4"> <FormMessage />
<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="acknowledgeRules" v-slot="{ value, handleChange }"> <FormField name="aknowledgeRules" 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 h-min" <span>By checking this box, you accept the <Button variant="link" class="p-0">Code of
@click.prevent.stop="showCoC = true">Code of
Conduct</Button>.</span> Conduct</Button>.</span>
</div> </div>
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<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" :disabled="readOnly">Submit Application</Button> <Button type="submit" :onClick="() => console.log('hi')" :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, nextTick } from "vue" import { ref, defineExpose, watch } 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,18 +21,11 @@ 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 "@shared/types/calendar" import { CalendarEvent } from "@/api/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
@@ -52,50 +45,45 @@ 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)
}
import { calendarEventSchema, parseLocalDateTime } from '@shared/schemas/calendarEventSchema' // ---------- schema ----------
import { createCalendarEvent, editCalendarEvent } from "@/api/calendar" const zEvent = z.object({
import DialogDescription from "../ui/dialog/DialogDescription.vue" name: z.string().min(2, "Please enter at least 2 characters").max(100),
const formSchema = toTypedSchema(calendarEventSchema) 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
})
}
})
const formSchema = toTypedSchema(zEvent)
// ---------- dialog state & defaults ---------- // ---------- dialog state & defaults ----------
const clickedDate = ref<string | null>(null);
const dialogOpen = ref(false) const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create'); function openDialog() { dialogOpen.value = true }
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: "",
@@ -104,77 +92,50 @@ function makeInitialValues() {
endDate: toLocalDateString(end), endDate: toLocalDateString(end),
endTime: toLocalTimeString(end), endTime: toLocalTimeString(end),
location: "", location: "",
color: "#6890ee", color: "#3b82f6",
description: "", description: "",
id: null as number | null, id: null as number | null,
} }
} }
const initialValues = ref(null) const initialValues = ref(makeInitialValues())
const formKey = ref(0) const formKey = ref(0)
watch(dialogOpen, async (isOpen) => { watch(dialogOpen, (isOpen) => {
if (isOpen) { if (!isOpen) {
await nextTick(); formKey.value++ // remounts the form -> picks up fresh initialValues
formRef.value?.resetForm({ values: makeInitialValues() })
} }
}) })
// ---------- submit ---------- // ---------- submit ----------
async function onSubmit(vals: z.infer<typeof calendarEventSchema>) { function onSubmit(vals: z.infer<typeof zEvent>) {
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
} }
try { console.log("Submitting CalendarEvent:", event)
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 ref="formRef" :key="formKey" v-slot="{ handleSubmit, resetForm }" :validation-schema="formSchema" <Form :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>{{ dialogMode == "edit" ? 'Edit Event' : 'Create Event' }}</DialogTitle> <DialogTitle>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"
@@ -189,48 +150,21 @@ function getColorName(hex: string) {
<FormControl> <FormControl>
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
<!-- Color --> <!-- Color -->
<div class="w-[120px]"> <div class="w-[60px]">
<FormField v-slot="{ componentField }" name="color"> <FormField v-slot="{ componentField }" name="color">
<FormItem> <FormItem>
<FormLabel>Color</FormLabel> <FormLabel>Color</FormLabel>
<FormControl> <FormControl>
<Select :modelValue="componentField.modelValue" <Input type="color" class="h-[38px] p-1 cursor-pointer"
@update:modelValue="componentField.onChange"> v-bind="componentField" />
<SelectTrigger>
<SelectValue asChild>
<template #default="{ selected }">
<div class="flex items-center gap-2 w-[70px]">
<span class="inline-block size-4 rounded"
:style="{ background: componentField.modelValue }">
</span>
<span>{{ getColorName(componentField.modelValue) }}</span>
</div>
</template>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in colorOptions" :key="opt.hex" :value="opt.hex">
<div class="flex items-center gap-2">
<span class="inline-block size-4 rounded"
:style="{ background: opt.hex }"></span>
<span>{{ opt.name }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
@@ -246,9 +180,7 @@ function getColorName(hex: string) {
<FormControl> <FormControl>
<Input type="date" v-bind="componentField" /> <Input type="date" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -259,9 +191,7 @@ function getColorName(hex: string) {
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
<!-- If you ever want native picker: type="time" --> <!-- If you ever want native picker: type="time" -->
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
@@ -274,9 +204,7 @@ function getColorName(hex: string) {
<FormControl> <FormControl>
<Input type="date" v-bind="componentField" /> <Input type="date" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -286,9 +214,7 @@ function getColorName(hex: string) {
<FormControl> <FormControl>
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
@@ -300,9 +226,7 @@ function getColorName(hex: string) {
<FormControl> <FormControl>
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -312,11 +236,9 @@ function getColorName(hex: string) {
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<FormControl> <FormControl>
<Textarea class="resize-none h-32 scrollbar-themed" v-bind="componentField" /> <Textarea class="resize-none h-32" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage/>
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -327,39 +249,9 @@ function getColorName(hex: string) {
</form> </form>
<DialogFooter> <DialogFooter>
<Button type="submit" form="dialogForm">{{ dialogMode == "edit" ? 'Update' : 'Create' }}</Button> <Button type="submit" form="dialogForm">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

@@ -1,289 +0,0 @@
<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';
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> {{ activeEvent.creator_name || "Unknown User" }}
</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">
<p>{{ person.member_name }}</p>
<p :class="statusColor(person.status)" class="text-right">
{{ displayStatus(person.status) }}
</p>
</div>
</div>
</div>
</section>
</div>
</div>
</template>

View File

@@ -1,47 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { Check, Search } from "lucide-vue-next" import { Check, Search } from "lucide-vue-next"
import { ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox" import { Combobox, ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox"
import { computed, onMounted, ref, watch } from "vue"; import { onMounted, ref } 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, DateValue } from "reka-ui" import type { DateRange } 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 { adminSubmitLOA, getLoaPolicy, getLoaTypes, submitLOA } from "@/api/loa"; // <-- import the submit function import { LOARequest, 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<{
@@ -52,226 +31,142 @@ const props = withDefaults(defineProps<{
member: null, member: null,
}); });
const df = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const userStore = useUserStore() const df = new DateFormatter("en-US", {
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 onSubmit = handleSubmit(async (values) => { const value = ref({
console.log(values); // start: new CalendarDate(2022, 1, 20),
const out: LOARequest = { // end: new CalendarDate(2022, 1, 20).add({ days: 20 }),
member_id: values.member_id, }) as Ref<DateRange>
start_date: values.start_date,
end_date: values.end_date, const reason = ref(""); // <-- reason for LOA
type_id: values.type.id, const submitting = ref(false);
reason: values.reason const submitError = ref<string | null>(null);
}; 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;
} }
try { if (props.adminMode) {
if (!props.adminMode) { members.value = await getMembers();
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 } });
}); });
const policyRef = ref<HTMLElement>(null); // Submit handler
async function handleSubmit() {
submitError.value = null;
submitSuccess.value = false;
submitting.value = true;
const defaultPlaceholder = today(getLocalTimeZone()) // Use currentMember if adminMode, otherwise use your own member id (stubbed as 89 here)
const member_id = currentMember.value?.member_id ?? 89;
const minEndDate = computed(() => { // Format dates as ISO strings
if (values.start_date) { const filed_date = toMariaDBDatetime(new Date());
return new CalendarDate(values.start_date.getFullYear(), values.start_date.getMonth() + 1, values.start_date.getDate()) const start_date = toMariaDBDatetime(value.value.start?.toDate(getLocalTimeZone()));
} else { const end_date = toMariaDBDatetime(value.value.end?.toDate(getLocalTimeZone()));
return null;
if (!member_id || !filed_date || !start_date || !end_date) {
submitError.value = "Missing required fields";
submitting.value = false;
return;
} }
})
const maxEndDate = computed(() => { const req: LOARequest = {
if (values.type && values.start_date) { filed_date,
let endDateObj = new Date(values.start_date.getTime() + values.type.max_length_days * 24 * 60 * 60 * 1000); start_date,
return new CalendarDate(endDateObj.getFullYear(), endDateObj.getMonth() + 1, endDateObj.getDate()) 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 {
return null; submitError.value = result.error || "Failed to submit LOA";
} }
}) }
function toMariaDBDatetime(date: Date): string {
return date.toISOString().slice(0, 19).replace('T', ' ');
}
</script> </script>
<template> <template>
<div class="flex flex-row-reverse gap-6 mx-auto w-full" :class="!adminMode ? 'max-w-5xl' : 'max-w-5xl'"> <div class="flex flex-row-reverse gap-6 mx-auto " :class="!adminMode ? 'max-w-5xl' : 'max-w-5xl'">
<div v-if="!adminMode" class="flex-1 flex flex-col space-x-4 rounded-md border p-4"> <div v-if="!adminMode" class="flex-1 flex space-x-4 rounded-md border p-4">
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">LOA Policy</p> <div class="flex-2 space-y-1">
<div ref="policyRef" class="bookstack-container"> <p class="text-sm font-medium leading-none">
<!-- LOA policy gets loaded here --> LOA Policy
</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">
<form @submit="onSubmit" class="flex flex-col gap-2"> <div class="flex w-full gap-5 ">
<div class="flex w-full gap-5"> <Combobox class="w-1/2" v-model="currentMember" :disabled="!adminMode">
<VeeField v-slot="{ field, errors }" name="member_id"> <ComboboxAnchor class="w-full">
<Field> <ComboboxInput placeholder="Search members..." class="w-full pl-9"
<FieldContent> :display-value="(v) => v ? v.member_name : ''" />
<FieldLabel>Member</FieldLabel> </ComboboxAnchor>
<Combobox :model-value="field.value" @update:model-value="field.onChange" <ComboboxList class="w-full">
:disabled="!adminMode"> <ComboboxEmpty class="text-muted-foreground">No results</ComboboxEmpty>
<ComboboxAnchor class="w-full"> <ComboboxGroup>
<ComboboxInput placeholder="Search members..." class="w-full pl-3" <template v-for="member in members" :key="member.member_id">
:display-value="(id) => { <ComboboxItem :value="member"
const m = members.find(mem => mem.member_id === id) class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5">
return m ? m.member_name : '' {{ member.member_name }}
}" /> <ComboboxItemIndicator class="absolute left-2 inline-flex items-center">
</ComboboxAnchor> <Check class="h-4 w-4" />
<ComboboxList class="*:w-64"> </ComboboxItemIndicator>
<ComboboxEmpty class="text-muted-foreground w-full">No results</ComboboxEmpty> </ComboboxItem>
<ComboboxGroup> </template>
<template v-for="member in members" :key="member.member_id"> </ComboboxGroup>
<ComboboxItem :value="member.member_id" </ComboboxList>
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5 w-full"> </Combobox>
{{ member.member_name }} <Popover>
<ComboboxItemIndicator <PopoverTrigger as-child>
class="absolute left-2 inline-flex items-center"> <Button variant="outline" :class="cn(
<Check class="h-4 w-4" /> 'w-1/2 justify-start text-left font-normal',
</ComboboxItemIndicator> !value && 'text-muted-foreground',
</ComboboxItem> )">
</template> <CalendarIcon class="mr-2 h-4 w-4" />
</ComboboxGroup> <template v-if="value.start">
</ComboboxList> <template v-if="value.end">
</Combobox> {{ df.format(value.start.toDate(getLocalTimeZone())) }} - {{
<div class="h-4"> df.format(value.end.toDate(getLocalTimeZone())) }}
<FieldError v-if="errors.length" :errors="errors"></FieldError> </template>
</div> <template v-else>
</FieldContent> {{ df.format(value.start.toDate(getLocalTimeZone())) }}
</Field> </template>
</VeeField> </template>
<VeeField v-slot="{ field, errors }" name="type"> <template v-else>
<Field class="w-full"> Pick a date
<FieldContent> </template>
<FieldLabel>Type</FieldLabel> </Button>
<Select :model-value="field.value" @update:model-value="field.onChange"> </PopoverTrigger>
<SelectTrigger class="w-full"> <PopoverContent class="w-auto p-0">
<SelectValue placeholder="Select type"></SelectValue> <RangeCalendar v-model="value" initial-focus :number-of-months="2"
</SelectTrigger> @update:start-value="(startDate) => value.start = startDate" />
<SelectContent> </PopoverContent>
<SelectItem v-for="type in loaTypes" :value="type"> </Popover>
{{ type.name }} </div>
</SelectItem> <Textarea v-model="reason" placeholder="Reason for LOA" class="w-full resize-none" />
</SelectContent> <div class="flex justify-end">
</Select> <Button :onClick="handleSubmit" :disabled="submitting" class="w-min">Submit</Button>
<div class="h-4"> </div>
<FieldError v-if="errors.length" :errors="errors"></FieldError> <div v-if="submitError" class="text-red-500 text-sm mt-2">{{ submitError }}</div>
</div> <div v-if="submitSuccess" class="text-green-500 text-sm mt-2">LOA submitted successfully!</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,53 +16,30 @@ 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 { cancelLOA, extendLOA, getAllLOAs, getMyLOAs } from "@/api/loa"; import { getAllLOAs, LOARequest } 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";
const props = defineProps<{
adminMode?: boolean
}>()
const LOAList = ref<LOARequest[]>([]); const LOAList = ref<LOARequest[]>([]);
onMounted(async () => { onMounted(async () => {
await loadLOAs(); LOAList.value = await getAllLOAs();
}); });
async function loadLOAs() { function formatDate(dateStr: string): string {
if (props.adminMode) { if (!dateStr) return "";
LOAList.value = await getAllLOAs(); return new Date(dateStr).toLocaleDateString("en-US", {
} 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: LOARequest): "Upcoming" | "Active" | "Overdue" | "Closed" { function loaStatus(loa: {
if (loa.closed) return "Closed"; start_date: string;
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);
@@ -70,9 +47,9 @@ function loaStatus(loa: LOARequest): "Upcoming" | "Active" | "Overdue" | "Closed
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 "Overdue"; if (now > end) return "Expired";
return "Overdue"; // fallback return "Expired"; // fallback
} }
function sortByStartDate(loas: LOARequest[]): LOARequest[] { function sortByStartDate(loas: LOARequest[]): LOARequest[] {
@@ -81,108 +58,50 @@ function sortByStartDate(loas: LOARequest[]): LOARequest[] {
); );
} }
async function cancelAndReload(id: number) { const sortedLoas = computed(() => sortByStartDate(LOAList.value));
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> <div class="w-5xl mx-auto">
<Dialog :open="isExtending" @update:open="(val) => isExtending = val"> <Table>
<DialogContent> <TableHeader>
<DialogHeader> <TableRow>
<DialogTitle>Extend {{ targetLOA.name }}'s Leave of Absence </DialogTitle> <TableHead class="w-[100px]">Member</TableHead>
</DialogHeader> <TableHead>Start</TableHead>
<div class="flex gap-5"> <TableHead>End</TableHead>
<Calendar v-model="extendTo" class="rounded-md border shadow-sm w-min" layout="month-and-year" <TableHead>Reason</TableHead>
:min-value="toCalendarDate(targetEnd)" <TableHead>Posted on</TableHead>
:max-value="toCalendarDate(targetEnd).add({ years: 1 })" /> <TableHead>Status</TableHead>
<div class="flex flex-col w-full gap-3 px-2"> </TableRow>
<p>Quick Options</p> </TableHeader>
<Button variant="outline" @click="extendTo = toCalendarDate(targetEnd).add({ days: 7 })">1 <TableBody>
Week</Button> <TableRow v-for="post in sortedLoas" :key="post.id" class="hover:bg-muted/50">
<Button variant="outline" @click="extendTo = toCalendarDate(targetEnd).add({ months: 1 })">1 <TableCell class="font-medium">
Month</Button> {{ post.name }}
</div> </TableCell>
</div> <TableCell>{{ formatDate(post.start_date) }}</TableCell>
<div class="flex justify-end gap-4"> <TableCell>{{ formatDate(post.end_date) }}</TableCell>
<Button variant="outline" @click="isExtending = false">Cancel</Button> <TableCell>{{ post.reason }}</TableCell>
<Button @click="commitExtend">Extend</Button> <TableCell>{{ formatDate(post.filed_date) }}</TableCell>
</div> <TableCell>
</DialogContent> <Badge v-if="loaStatus(post) === 'Upcoming'" class="bg-blue-500">Upcoming</Badge>
</Dialog> <Badge v-else-if="loaStatus(post) === 'Active'" class="bg-green-500">Active</Badge>
<div class="max-w-7xl w-full mx-auto"> <Badge v-else-if="loaStatus(post) === 'Expired'" class="bg-gray-400">Expired</Badge>
<Table> <Badge v-else class="bg-red-500">Cancelled</Badge>
<TableHeader> </TableCell>
<TableRow> <TableCell @click.stop="console.log('hi')" class="text-right">
<TableHead>Member</TableHead> <DropdownMenu>
<TableHead>Type</TableHead> <DropdownMenuTrigger class="cursor-pointer">
<TableHead>Start</TableHead> <Ellipsis></Ellipsis>
<TableHead>End</TableHead> </DropdownMenuTrigger>
<TableHead class="w-[500px]">Reason</TableHead> <DropdownMenuContent>
<TableHead>Posted on</TableHead> <DropdownMenuItem :variant="'destructive'">Cancel</DropdownMenuItem>
<TableHead>Status</TableHead> </DropdownMenuContent>
</TableRow> </DropdownMenu>
</TableHeader> </TableCell>
<TableBody>
<TableRow v-for="post in LOAList" :key="post.id" class="hover:bg-muted/50"> </TableRow>
<TableCell class="font-medium"> </TableBody>
{{ post.name }} </Table>
</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

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

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

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

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

@@ -1,22 +0,0 @@
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,14 +1,7 @@
<script setup> <script setup>
import { getLocalTimeZone, today } from "@internationalized/date"; import { reactiveOmit } from "@vueuse/core";
import { createReusableTemplate, reactiveOmit, useVModel } from "@vueuse/core"; import { CalendarRoot, useForwardPropsEmits } from "reka-ui";
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,
@@ -45,165 +38,34 @@ 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, default: undefined }, modelValue: { type: null, required: false },
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: null, required: false }, as: { type: [String, Object, Function], 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", "layout", "placeholder"); const delegatedProps = reactiveOmit(props, "class");
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, date }" v-slot="{ grid, weekDays }"
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 class="pt-0"> <CalendarHeader>
<nav <CalendarHeading />
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>
<slot <div class="flex items-center gap-1">
name="calendar-heading" <CalendarPrevButton />
:date="date" <CalendarNextButton />
:month="ReuseMonthTemplate" </div>
: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: null, required: false }, as: { type: [String, Object, Function], 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: null, required: false, default: "button" }, as: { type: [String, Object, Function], 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: null, required: false }, as: { type: [String, Object, Function], 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: null, required: false }, as: { type: [String, Object, Function], 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: null, required: false }, as: { type: [String, Object, Function], 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: null, required: false }, as: { type: [String, Object, Function], 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: null, required: false }, as: { type: [String, Object, Function], 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 flex-1 font-normal text-[0.8rem]', 'text-muted-foreground rounded-md w-8 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: null, required: false }, as: { type: [String, Object, Function], required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });
@@ -18,10 +18,7 @@ const forwardedProps = useForwardProps(delegatedProps);
<CalendarHeader <CalendarHeader
data-slot="calendar-header" data-slot="calendar-header"
:class=" :class="
cn( cn('flex justify-center pt-1 relative items-center w-full', props.class)
'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: null, required: false }, as: { type: [String, Object, Function], 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: null, required: false }, as: { type: [String, Object, Function], required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });
@@ -23,6 +23,7 @@ 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: null, required: false }, as: { type: [String, Object, Function], required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });
@@ -23,6 +23,7 @@ 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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