2 Commits

Author SHA1 Message Date
ea52be83ef Create dossier
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m29s
2025-12-06 15:40:51 -06:00
9c903c9ad9 Create dossier
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m19s
2025-12-05 17:59:04 -06:00
56 changed files with 780 additions and 2121 deletions

View File

@@ -1,8 +1,6 @@
name: Continuous Deployment
on:
push:
tags:
- '*'
jobs:
Deploy:
@@ -10,29 +8,20 @@ jobs:
runs-on: ubuntu-latest
container:
volumes:
- /var/www/html/milsim-site-v4:/var/www/html/milsim-site-v4:z
- /var/www/html/milsim-site-v4:/var/www/html/milsim-site-v4:rw
steps:
- name: Setup Local Environment
run: |
groupadd -g 989 nginx || true
useradd nginx -u 990 -g nginx -m || true
- name: Update Node Environment
uses: actions/setup-node@v6
with:
node-version: 20.19
- name: Verify Local Environment
- name: Verify Node Environment
run: |
which npm
npm -v
which node
node -v
which sed
sed --version
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: 'main'
@@ -43,53 +32,31 @@ jobs:
cp /workspace/17th-Ranger-Battalion-ORG/milsim-site-v4/.git/config .git/config
chown nginx:nginx .git/config
- name: Update Application Code
run: |
cd /var/www/html/milsim-site-v4
version=`git log -1 --format=%H`
echo "Current Revision: $version"
echo "Updating to: ${{ github.sha }}
sudo -u nginx git reset --hard
sudo -u nginx git pull origin main
sudo -u nginx git pull origin main
new_version=`git log -1 --format=%H`
echo "Sucessfully updated to: $new_version
- name: Update Shared Dependencies and Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/shared
npm install
chown -R nginx:nginx .
- name: Update UI Dependencies and Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/ui
npm install
chown -R nginx:nginx .
- name: Update API Dependencies and Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/api
npm install
chown -R nginx:nginx .
- name: Build UI / Update Version / Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/ui
npm run build
version=`git describe --abbrev=0 --tags`
sed -i "s/VITE_APPLICATION_VERSION=.*/VITE_APPLICATION_VERSION=$version/" .env
chown -R nginx:nginx .
- name: Build API / Update Version / Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/api
npm run build
version=`git describe --abbrev=0 --tags`
sed -i "s/APPLICATION_VERSION=.*/APPLICATION_VERSION=$version/" .env
chown -R nginx:nginx .
- name: Reset File Permissions
- name: Fix File Permissions
run: |
sudo chown -R nginx:nginx /var/www/html/milsim-site-v4
sudo chmod -R u+w /var/www/html/milsim-site-v4
- name: Update Application Code
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4 && git reset --hard && git pull origin main"
- name: Update Shared Dependencies
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/shared && npm install"
- name: Update UI Dependencies
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/ui && npm install"
- name: Update API Dependencies
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/api && npm install"
- name: Build UI
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/ui && npm run build"
- name: Build API
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/api && npm run build"

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

View File

@@ -19,14 +19,7 @@ AUTH_END_SESSION_URI=
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
# Glitchtip
GLITCHTIP_DSN=
DISABLE_GLITCHTIP= # true/false
# Bookstack
DOC_HOST= # https://bookstack.whatever.com/
DOC_TOKEN_SECRET=
DOC_TOKEN_ID=

View File

@@ -20,14 +20,11 @@ const port = process.env.SERVER_PORT;
//glitchtip setup
const sentry = require('@sentry/node');
if (process.env.DISABLE_GLITCHTIP === "true") {
console.log("Glitchtip disabled")
if (!process.env.DISABLE_GLITCHTIP) {
console.log("Glitchtip disabled AAAAAA")
} 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 });
sentry.init({ dsn: dsn });
console.log("Glitchtip initialized");
}
@@ -61,7 +58,6 @@ const { roles, memberRoles } = require('./routes/roles');
const { courseRouter, eventRouter } = require('./routes/course');
const { calendarRouter } = require('./routes/calendar')
const morgan = require('morgan');
const { env } = require('process');
app.use('/application', applicationsRouter);
app.use('/ranks', ranks);

View File

@@ -2,13 +2,11 @@ const express = require('express');
const router = express.Router();
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 { getRankByName, insertMemberRank } from '../services/rankService';
import { ApplicationFull, CommentRow } from "@app/shared/types/application"
import { assignUserToStatus } from '../services/statusService';
import { Request, Response } from 'express';
import { getUserRoles } from '../services/rolesService';
// POST /application
router.post('/', async (req, res) => {
@@ -18,13 +16,13 @@ router.post('/', async (req, res) => {
const appVersion = 1;
await createApplication(memberID, appVersion, JSON.stringify(App))
await setUserState(memberID, MemberState.Applicant);
createApplication(memberID, appVersion, JSON.stringify(App))
setUserState(memberID, MemberState.Applicant);
res.sendStatus(201);
} catch (err) {
console.error('Failed to create application: \n', err);
res.status(500).json({ error: 'Failed to create application' });
console.error('Insert failed:', err);
res.status(500).json({ error: 'Failed to save application' });
}
});
@@ -39,20 +37,6 @@ 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) => {
let userID = req.user.id;
@@ -78,17 +62,13 @@ router.get('/me', async (req, res) => {
})
// GET /application/:id
router.get('/me/:id', async (req: Request, res: Response) => {
let appID = Number(req.params.id);
let member = req.user.id;
router.get('/:id', async (req, res) => {
let appID = req.params.id;
console.log("HELLO")
try {
const application = await getApplicationByID(appID);
if (application === undefined)
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);
@@ -104,42 +84,6 @@ 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
router.post('/approve/:id', async (req, res) => {
const appID = req.params.id;
@@ -148,6 +92,9 @@ router.post('/approve/:id', async (req, res) => {
const app = await getApplicationByID(appID);
const result = await approveApplication(appID);
console.log("START");
console.log(app, result);
//guard against failures
if (result.affectedRows != 1) {
throw new Error("Something went wrong approving the application");
@@ -172,11 +119,26 @@ router.post('/approve/:id', async (req, res) => {
router.post('/deny/:id', async (req, res) => {
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 {
const app = await getApplicationByID(appID);
await denyApplication(appID);
await setUserState(app.member_id, MemberState.Denied);
const result = await pool.execute(sql, appID);
console.log(result);
if (result.affectedRows === 0) {
res.status(400).json('Something went wrong denying the application');
}
if (result.affectedRows == 1) {
res.sendStatus(200);
}
} catch (err) {
console.error('Approve failed:', err);
res.status(500).json({ error: 'Failed to deny application' });
@@ -184,12 +146,10 @@ router.post('/deny/:id', async (req, res) => {
});
// 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 data = req.body.message;
const user = req.user;
console.log(user)
const user = 1;
const sql = `INSERT INTO application_comments(
application_id,
@@ -201,7 +161,7 @@ VALUES(?, ?, ?);`
try {
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)
if (result.affectedRows !== 1) {
conn.release();
@@ -226,60 +186,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;

View File

@@ -21,13 +21,12 @@ passport.use(new OpenIDConnectStrategy({
scope: ['openid', 'profile']
}, async function verify(issuer, sub, profile, jwtClaims, accessToken, refreshToken, params, cb) {
console.log('--- OIDC verify() called ---');
console.log('issuer:', issuer);
console.log('sub:', sub);
// console.log('--- OIDC verify() called ---');
// console.log('issuer:', issuer);
// console.log('sub:', sub);
// console.log('profile:', JSON.stringify(profile, null, 2));
console.log('profile:', profile);
console.log('id_token claims:', JSON.stringify(jwtClaims, null, 2));
console.log('preferred_username:', jwtClaims?.preferred_username);
// console.log('id_token claims:', JSON.stringify(jwtClaims, null, 2));
// console.log('preferred_username:', jwtClaims?.preferred_username);
const con = await pool.getConnection();
try {

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();
import pool from '../db';
import { getUserActiveLOA } from '../services/loaService';
import { getUserData } from '../services/memberService';
import { getUserRoles } from '../services/rolesService';
@@ -41,13 +40,12 @@ router.get('/me', async (req, res) => {
try {
const { id, name, state } = await getUserData(req.user.id);
// const LOAData = await pool.query(
// `SELECT *
// FROM leave_of_absences
// WHERE member_id = ?
// AND deleted = 0
// AND UTC_TIMESTAMP() BETWEEN start_date AND end_date;`, req.user.id);
const LOAData = await getUserActiveLOA(req.user.id);
const LOAData = await pool.query(
`SELECT *
FROM leave_of_absences
WHERE member_id = ?
AND deleted = 0
AND UTC_TIMESTAMP() BETWEEN start_date AND end_date;`, req.user.id);
const roleData = await getUserRoles(req.user.id);

View File

@@ -1,6 +1,5 @@
import { ApplicationListRow, ApplicationRow, CommentRow } from "@app/shared/types/application";
import pool from "../db";
import { error } from "console";
export async function createApplication(memberID: number, appVersion: number, app: string) {
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
FROM applications AS app
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]);
return app[0];
}
export async function getApplicationByID(appID: number): Promise<ApplicationRow> {
const sql =
`SELECT app.*,
@@ -46,20 +44,7 @@ export async function getApplicationList(): Promise<ApplicationListRow[]> {
return rows;
}
export async function getAllMemberApplications(memberID: number): Promise<ApplicationListRow[]> {
const sql = `SELECT
app.id,
app.member_id,
app.submitted_at,
app.app_status
FROM applications AS app WHERE app.member_id = ? ORDER BY submitted_at DESC;`;
const rows: ApplicationListRow[] = await pool.query(sql, [memberID])
return rows;
}
export async function approveApplication(id: number) {
export async function approveApplication(id) {
const sql = `
UPDATE applications
SET approved_at = NOW()
@@ -72,38 +57,15 @@ export async function approveApplication(id: number) {
return result;
}
export async function denyApplication(id: number) {
const sql = `
UPDATE applications
SET denied_at = NOW()
WHERE id = ?
AND approved_at IS NULL
AND denied_at IS NULL
`;
const result = await pool.execute(sql, id);
if (result.affectedRows == 1) {
return
} else {
throw new Error(`"Something went wrong denying application with ID ${id}`);
}
}
export async function getApplicationComments(appID: number, admin: boolean = false): Promise<CommentRow[]> {
const excludeAdmin = ' AND app.admin_only = false';
const whereClause = `WHERE app.application_id = ?${!admin ? excludeAdmin : ''}`;
export async function getApplicationComments(appID: number): Promise<CommentRow[]> {
return await pool.query(`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
${whereClause}`,
WHERE app.application_id = ?;`,
[appID]);
}

View File

@@ -123,9 +123,15 @@ export async function setAttendanceStatus(memberID: number, eventID: number, sta
}
export async function getEventAttendance(eventID: number): Promise<CalendarSignup[]> {
const sql = `
SELECT
s.member_id,
s.status,
m.name AS member_name
FROM calendar_events_signups s
LEFT JOIN members m ON s.member_id = m.id
WHERE s.event_id = ?
`;
const sql = "CALL `sp_GetCalendarEventSignups`(?)"
const res = await pool.query(sql, [eventID]);
console.log(res[0]);
return res[0];
return await pool.query(sql, [eventID]);
}

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

@@ -5,7 +5,6 @@ module.exports = {
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

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

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

View File

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

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,8 +1,5 @@
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");

View File

@@ -1,9 +1,6 @@
# 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=

View File

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

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
const addr = import.meta.env.VITE_APIHOST;
export async function loadApplication(id: number | string, asAdmin: boolean = false): Promise<ApplicationFull | null> {
const res = await fetch(`${addr}/application/${id}?admin=${asAdmin}`, { credentials: 'include' })
export async function loadApplication(id: number | string): Promise<ApplicationFull | null> {
const res = await fetch(`${addr}/application/${id}`, { credentials: 'include' })
if (res.status === 204) return null
if (!res.ok) throw new Error('Failed to load application')
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`, {
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' },
body: JSON.stringify(out),
})
@@ -68,26 +121,6 @@ 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) {
const res = await fetch(`${addr}/application/approve/${id}`, { method: 'POST' })
@@ -103,14 +136,3 @@ export async function denyApplication(id: Number) {
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")
}
}

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
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",
},
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) {
@@ -36,7 +26,6 @@ export async function adminSubmitLOA(request: LOARequest): Promise<{ id?: number
}
}
export async function getMyLOA(): Promise<LOARequest | null> {
const res = await fetch(`${addr}/loa/me`, {
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

@@ -166,75 +166,3 @@
@apply bg-background text-foreground;
}
}
/* Root container */
.ListRendererV2-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 */
.ListRendererV2-container h4 {
margin: 0.9rem 0 0.4rem 0;
font-weight: 600;
line-height: 1.35;
font-size: 1.05rem;
color: var(--foreground);
/* PURE WHITE */
}
.ListRendererV2-container h5 {
margin: 0.9rem 0 0.4rem 0;
font-weight: 600;
line-height: 1.35;
font-size: 0.95rem;
color: var(--foreground);
/* Still white (change to muted if desired) */
}
/* Lists */
.ListRendererV2-container ul {
list-style-type: disc;
margin-left: 1.1rem;
margin-bottom: 0.6rem;
padding-left: 0.6rem;
color: var(--muted-foreground);
/* dim text */
}
/* Nested lists */
.ListRendererV2-container ul ul {
list-style-type: circle;
margin-left: 0.9rem;
}
/* List items */
.ListRendererV2-container li {
margin: 0.15rem 0;
padding-left: 0.1rem;
color: var(--muted-foreground);
}
/* Bullet color */
.ListRendererV2-container li::marker {
color: var(--muted-foreground);
}
/* Inline elements */
.ListRendererV2-container li p,
.ListRendererV2-container li span,
.ListRendererV2-container p {
display: inline;
margin: 0;
padding: 0;
color: var(--muted-foreground);
}
/* Top-level spacing */
.ListRendererV2-container>ul>li {
margin-top: 0.3rem;
}

View File

@@ -18,15 +18,13 @@ import NavigationMenuTrigger from '../ui/navigation-menu/NavigationMenuTrigger.v
import NavigationMenuContent from '../ui/navigation-menu/NavigationMenuContent.vue';
import { navigationMenuTriggerStyle } from '../ui/navigation-menu/'
import { useAuth } from '@/composables/useAuth';
import { ArrowUpRight, CircleArrowOutUpRight } from 'lucide-vue-next';
import { 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;
@@ -60,15 +58,10 @@ function blurAfter() {
</NavigationMenuLink>
</NavigationMenuItem>
<!-- Docs -->
<!-- Members -->
<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>
<RouterLink to="/" @click="blurAfter">Documents</RouterLink>
</NavigationMenuLink>
</NavigationMenuItem>
@@ -163,12 +156,6 @@ function blurAfter() {
<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>
@@ -183,7 +170,6 @@ function blurAfter() {
<!-- <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>

View File

@@ -11,18 +11,13 @@ import {
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { toTypedSchema } from '@vee-validate/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<{
messages: CommentRow[]
messages: Array<Record<string, any>>
}>()
const emit = defineEmits<{
(e: 'post', text: string): void
(e: 'postInternal', text: string): void
}>()
const commentSchema = toTypedSchema(
@@ -31,13 +26,8 @@ const commentSchema = toTypedSchema(
})
)
const submitMode = ref("public");
// vee-validate passes (values, actions) to @submit
function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => void }) {
if (submitMode.value === "internal")
emit('postInternal', values.text.trim())
else
emit('post', values.text.trim())
resetForm()
}
@@ -58,24 +48,18 @@ function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => vo
</FormField>
<!-- Button below, right-aligned -->
<div class="mt-2 flex justify-end gap-2">
<Button type="submit" @click="submitMode = 'internal'" variant="outline">Post (Internal)</Button>
<Button type="submit" @click="submitMode = 'public'">Post (Public)</Button>
<div class="mt-2 flex justify-end">
<Button type="submit">Post</Button>
</div>
</Form>
<!-- Existing posts -->
<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"
:class="message.admin_only ? 'border-amber-300/70' : 'border-neutral-800'">
<div v-for="(message, i) in props.messages" :key="message.id ?? i"
class="rounded-md border border-neutral-800 p-3 space-y-5">
<!-- Comment header -->
<div class="flex justify-between">
<div class="flex">
<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", {
year: "numeric",
month: "long",

View File

@@ -16,27 +16,23 @@ import { Form } from 'vee-validate';
import { onMounted, ref } from 'vue';
import * as z from 'zod';
import DateInput from '../form/DateInput.vue';
import { ApplicationData } from '@shared/types/application';
const regexA = /^https?:\/\/steamcommunity\.com\/id\/[A-Za-z0-9_]+\/?$/;
const regexB = /^https?:\/\/steamcommunity\.com\/profiles\/\d+\/?$/;
import { ApplicationData } from '@/api/application';
const formSchema = toTypedSchema(z.object({
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"),
hobbies: z.string().nonempty(),
hobbies: z.string(),
military: z.boolean(),
communities: z.string().nonempty(),
joinReason: z.string().nonempty(),
milsimAttraction: z.string().nonempty(),
referral: z.string().nonempty(),
steamProfile: z.string().nonempty().refine((val) => regexA.test(val) || regexB.test(val), { message: "Invalid Steam profile URL." }),
timezone: z.string().nonempty(),
communities: z.string(),
joinReason: z.string(),
milsimAttraction: z.string(),
referral: z.string(),
steamProfile: z.string(),
timezone: z.string(),
canAttendSaturday: z.boolean(),
interests: z.string().nonempty(),
acknowledgeRules: z.literal(true, {
interests: z.string(),
aknowledgeRules: z.literal(true, {
errorMap: () => ({ message: "Required" })
}),
}))
@@ -45,7 +41,7 @@ const formSchema = toTypedSchema(z.object({
const fallbackInitials = {
military: false,
canAttendSaturday: false,
acknowledgeRules: false,
aknowledgeRules: false,
}
const props = defineProps<{
@@ -86,9 +82,7 @@ onMounted(() => {
<FormControl>
<DateInput :model-value="(value as string) ?? ''" :disabled="readOnly" @update:model-value="handleChange" />
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>
@@ -100,9 +94,7 @@ onMounted(() => {
<FormControl>
<Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>
@@ -113,9 +105,7 @@ onMounted(() => {
<FormControl>
<Input type="number" :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>
@@ -127,9 +117,7 @@ onMounted(() => {
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" />
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>
@@ -143,9 +131,7 @@ onMounted(() => {
<span>Yes (checked) / No (unchecked)</span>
</div>
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>
@@ -156,9 +142,7 @@ onMounted(() => {
<FormControl>
<Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>
@@ -170,9 +154,7 @@ onMounted(() => {
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" />
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>
@@ -184,9 +166,7 @@ onMounted(() => {
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" />
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>
@@ -198,9 +178,7 @@ onMounted(() => {
<Input placeholder="e.g., Reddit / Member: Alice" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" />
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>
@@ -216,9 +194,7 @@ onMounted(() => {
<Input type="url" placeholder="https://steamcommunity.com/profiles/7656119..." :model-value="value"
@update:model-value="handleChange" :disabled="readOnly" />
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>
@@ -230,9 +206,7 @@ onMounted(() => {
<Input placeholder="e.g., AEST, EST, UTC+10" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" />
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>
@@ -246,9 +220,7 @@ onMounted(() => {
<span>Yes (checked) / No (unchecked)</span>
</div>
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>
@@ -260,26 +232,22 @@ onMounted(() => {
<Input placeholder="e.g., Rifleman; Medic; Pilot" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" />
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>
<!-- Code of Conduct (boolean, field name kept as-is) -->
<FormField name="acknowledgeRules" v-slot="{ value, handleChange }">
<FormField name="aknowledgeRules" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>Community Code of Conduct</FormLabel>
<FormControl>
<div class="flex items-center gap-2">
<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">Code of
<span>By checking this box, you accept the <Button variant="link" class="p-0">Code of
Conduct</Button>.</span>
</div>
</FormControl>
<div class="h-4">
<FormMessage class="text-destructive" />
</div>
<FormMessage />
</FormItem>
</FormField>

View File

@@ -1,7 +1,7 @@
<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 { CircleAlert, Clock, EllipsisVertical, MapPin, User, X } from 'lucide-vue-next';
import { computed, onMounted, ref, watch } from 'vue';
import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
import Button from '../ui/button/Button.vue';
import { CalendarAttendance, getCalendarEvent, setCalendarEventAttendance, setCancelCalendarEvent } from '@/api/calendar';
@@ -11,13 +11,24 @@ 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 eventID = computed(() => {
// const id = route.params.id;
// if (typeof id === 'string') return id;
// return undefined;
// });
const loaded = ref<boolean>(false);
const activeEvent = ref<CalendarEvent | null>(null);
// onMounted(async () => {
// let eventID = route.params.id;
// console.log(eventID);
// activeEvent.value = await getCalendarEvent(Number(eventID));
// loaded.value = true;
// });
watch(
() => route.params.id,
async (id) => {
@@ -34,27 +45,23 @@ const emit = defineEmits<{
(e: 'edit', event: CalendarEvent): void
}>()
const dateFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'long', month: 'short', day: 'numeric',
})
// const activeEvent = computed(() => props.event)
const timeFmt = new Intl.DateTimeFormat(undefined, {
const startFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit'
})
const endFmt = new Intl.DateTimeFormat(undefined, {
hour: 'numeric', minute: '2-digit'
})
const 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 whenText = computed(() => {
if (!activeEvent.value?.start) return ''
const s = new Date(activeEvent.value.start)
const e = activeEvent.value?.end ? new Date(activeEvent.value.end) : null
return e
? `${startFmt.format(s)} ${endFmt.format(e)}`
: `${startFmt.format(s)}`
})
const attending = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Attending) })
@@ -64,7 +71,6 @@ 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;
@@ -77,7 +83,6 @@ async function setAttendance(state: CalendarAttendance) {
}
const canEditEvent = computed(() => {
if (!user.isLoggedIn) return false;
if (user.user.id == activeEvent.value.creator_id)
return true;
});
@@ -92,94 +97,17 @@ 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 })
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">
<div class="flex items-center justify-between gap-3 border-b px-4 py-3">
<h2 class="text-lg font-semibold break-all">
{{ activeEvent?.name || 'Event' }}
</h2>
<div class="flex gap-4 items-center">
<div class="flex gap-4">
<DropdownMenu v-if="canEditEvent">
<DropdownMenuTrigger>
<button
@@ -191,7 +119,8 @@ defineExpose({ forceReload })
<DropdownMenuItem @click="emit('edit', activeEvent)">
Edit
</DropdownMenuItem>
<DropdownMenuItem v-if="activeEvent.cancelled" @click="setCancel(false)">
<DropdownMenuItem v-if="activeEvent.cancelled"
@click="setCancel(false)">
Un-Cancel
</DropdownMenuItem>
<DropdownMenuItem v-else @click="setCancel(true)">
@@ -213,7 +142,7 @@ defineExpose({ forceReload })
<CircleAlert></CircleAlert> This event has been cancelled
</div>
</section>
<section v-if="isPast && user.isLoggedIn" class="w-full">
<section class="w-full">
<ButtonGroup class="flex w-full">
<Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''"
@@ -226,22 +155,25 @@ defineExpose({ forceReload })
@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" }}
<!-- When -->
<section v-if="whenText" class="space-y-2 w-full">
<div class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<Clock class="size-4 opacity-80" />
<span class="font-medium">{{ whenText }}</span>
</div>
</section>
<!-- Quick meta chips -->
<section class="flex flex-wrap gap-2 w-full">
<span class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<MapPin class="size-3.5 opacity-80" />
<span class="font-medium">{{ activeEvent.location || "Unknown" }}</span>
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<User class="size-3.5 opacity-80" />
<span class="font-medium">Created by: {{ activeEvent.creator_name || "Unknown User"
}}</span>
</span>
</section>
<!-- Description -->
<section class="space-y-2 w-full">
<p class="text-lg font-semibold">Description</p>
@@ -249,41 +181,46 @@ defineExpose({ forceReload })
{{ activeEvent.description }}
</p>
</section>
<!-- attendance -->
<!-- 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>
<label
:class="viewedState === CalendarAttendance.Attending ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="viewedState = CalendarAttendance.Attending">Going {{ attending.length }}</label>
<label
:class="viewedState === CalendarAttendance.Maybe ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="viewedState = CalendarAttendance.Maybe">Maybe {{ maybe.length }}</label>
<label
:class="viewedState === CalendarAttendance.NotAttending ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="viewedState = CalendarAttendance.NotAttending">Declined {{ declined.length
}}</label>
</div>
<div class="pb-1 min-h-48">
<div class="grid grid-cols-2 font-semibold text-muted-foreground border-b py-1 px-3 mb-2">
<p>Name</p>
<p class="text-right">Status</p>
</div>
<div v-for="person in attendanceList" :key="person.member_id"
class="grid grid-cols-2 py-1 *:px-3 hover:bg-muted">
<div class="px-5 py-4 min-h-28">
<div v-if="viewedState === CalendarAttendance.Attending" v-for="person in attending">
<p>{{ person.member_name }}</p>
</div>
<div v-if="viewedState === CalendarAttendance.Maybe" v-for="person in maybe">
<p>{{ person.member_name }}</p>
</div>
<div v-if="viewedState === CalendarAttendance.NotAttending" v-for="person in declined">
<p>{{ person.member_name }}</p>
<p :class="statusColor(person.status)" class="text-right">
{{ displayStatus(person.status) }}
</p>
</div>
</div>
</div>
</section>
</div>
<!-- Footer (optional actions) -->
<!-- <div class="border-t px-4 py-3 flex items-center justify-end gap-2">
<button class="rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40 transition">
Edit
</button>
<button
class="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm hover:opacity-90 transition">
Open details
</button>
</div> -->
</div>
</template>

View File

@@ -1,47 +1,26 @@
<script setup lang="ts">
import { Check, Search } from "lucide-vue-next"
import { ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox"
import { computed, onMounted, ref, watch } from "vue";
import { Combobox, ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox"
import { onMounted, ref } from "vue";
import { Member, getMembers } from "@/api/member";
import Button from "@/components/ui/button/Button.vue";
import {
CalendarDate,
DateFormatter,
fromDate,
getLocalTimeZone,
parseDate,
today,
} from "@internationalized/date"
import type { DateRange, DateValue } from "reka-ui"
import type { DateRange } from "reka-ui"
import type { Ref } from "vue"
import Popover from "@/components/ui/popover/Popover.vue";
import PopoverTrigger from "@/components/ui/popover/PopoverTrigger.vue";
import PopoverContent from "@/components/ui/popover/PopoverContent.vue";
import { RangeCalendar } from "@/components/ui/range-calendar"
import { cn } from "@/lib/utils";
import { CalendarIcon } from "lucide-vue-next"
import Textarea from "@/components/ui/textarea/Textarea.vue";
import { adminSubmitLOA, getLoaPolicy, getLoaTypes, submitLOA } from "@/api/loa"; // <-- import the submit function
import { LOARequest, LOAType } from "@shared/types/loa";
import { useForm, Field as VeeField } from "vee-validate";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from '@/components/ui/field'
import Combobox from "../ui/combobox/Combobox.vue";
import Select from "../ui/select/Select.vue";
import SelectTrigger from "../ui/select/SelectTrigger.vue";
import SelectValue from "../ui/select/SelectValue.vue";
import SelectContent from "../ui/select/SelectContent.vue";
import SelectItem from "../ui/select/SelectItem.vue";
import FieldError from "../ui/field/FieldError.vue";
import { LOARequest, submitLOA } from "@/api/loa"; // <-- import the submit function
const members = ref<Member[]>([])
const loaTypes = ref<LOAType[]>();
const policyString = ref<string | null>(null);
const currentMember = ref<Member | null>(null);
const props = withDefaults(defineProps<{
@@ -52,115 +31,102 @@ const props = withDefaults(defineProps<{
member: null,
});
const df = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const userStore = useUserStore()
//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 df = new DateFormatter("en-US", {
dateStyle: "medium",
})
const onSubmit = handleSubmit(async (values) => {
console.log(values);
const out: LOARequest = {
member_id: values.member_id,
start_date: values.start_date,
end_date: values.end_date,
type_id: values.type.id,
reason: values.reason
};
if (props.adminMode) {
await adminSubmitLOA(out);
} else {
await submitLOA(out);
userStore.loadUser();
}
})
const value = ref({
// start: new CalendarDate(2022, 1, 20),
// end: new CalendarDate(2022, 1, 20).add({ days: 20 }),
}) as Ref<DateRange>
const reason = ref(""); // <-- reason for LOA
const submitting = ref(false);
const submitError = ref<string | null>(null);
const submitSuccess = ref(false);
onMounted(async () => {
if (props.member) {
currentMember.value = props.member;
}
try {
if (!props.adminMode) {
let policy = await getLoaPolicy() as any;
policyString.value = policy;
policyRef.value.innerHTML = policyString.value;
}
} catch (error) {
console.error(error);
}
if (props.adminMode) {
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(() => {
if (values.start_date) {
return new CalendarDate(values.start_date.getFullYear(), values.start_date.getMonth() + 1, values.start_date.getDate())
} else {
return null;
// Format dates as ISO strings
const filed_date = toMariaDBDatetime(new Date());
const start_date = toMariaDBDatetime(value.value.start?.toDate(getLocalTimeZone()));
const end_date = toMariaDBDatetime(value.value.end?.toDate(getLocalTimeZone()));
if (!member_id || !filed_date || !start_date || !end_date) {
submitError.value = "Missing required fields";
submitting.value = false;
return;
}
})
const maxEndDate = computed(() => {
if (values.type && values.start_date) {
let endDateObj = new Date(values.start_date.getTime() + values.type.max_length_days * 24 * 60 * 60 * 1000);
return new CalendarDate(endDateObj.getFullYear(), endDateObj.getMonth() + 1, endDateObj.getDate())
const req: LOARequest = {
filed_date,
start_date,
end_date,
reason: reason.value,
member_id
};
const result = await submitLOA(req);
submitting.value = false;
if (result.id) {
submitSuccess.value = true;
reason.value = "";
} else {
return null;
submitError.value = result.error || "Failed to submit LOA";
}
})
}
function toMariaDBDatetime(date: Date): string {
return date.toISOString().slice(0, 19).replace('T', ' ');
}
</script>
<template>
<div class="flex flex-row-reverse gap-6 mx-auto w-full" :class="!adminMode ? 'max-w-5xl' : 'max-w-5xl'">
<div v-if="!adminMode" class="flex-1 flex flex-col space-x-4 rounded-md border p-4">
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">LOA Policy</p>
<div ref="policyRef">
<!-- LOA policy gets loaded here -->
<div v-if="!adminMode" class="flex-1 flex space-x-4 rounded-md border p-4">
<div class="flex-2 space-y-1">
<p class="text-sm font-medium leading-none">
LOA Policy
</p>
<p class="text-sm text-muted-foreground">
Policy goes here.
</p>
</div>
</div>
<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">
<VeeField v-slot="{ field, errors }" name="member_id">
<Field>
<FieldContent>
<FieldLabel>Member</FieldLabel>
<Combobox :model-value="field.value" @update:model-value="field.onChange"
:disabled="!adminMode">
<div class="flex w-full gap-5 ">
<Combobox class="w-1/2" v-model="currentMember" :disabled="!adminMode">
<ComboboxAnchor class="w-full">
<ComboboxInput placeholder="Search members..." class="w-full pl-3"
:display-value="(id) => {
const m = members.find(mem => mem.member_id === id)
return m ? m.member_name : ''
}" />
<ComboboxInput placeholder="Search members..." class="w-full pl-9"
:display-value="(v) => v ? v.member_name : ''" />
</ComboboxAnchor>
<ComboboxList class="*:w-64">
<ComboboxEmpty class="text-muted-foreground w-full">No results</ComboboxEmpty>
<ComboboxList class="w-full">
<ComboboxEmpty class="text-muted-foreground">No results</ComboboxEmpty>
<ComboboxGroup>
<template v-for="member in members" :key="member.member_id">
<ComboboxItem :value="member.member_id"
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5 w-full">
<ComboboxItem :value="member"
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5">
{{ member.member_name }}
<ComboboxItemIndicator
class="absolute left-2 inline-flex items-center">
<ComboboxItemIndicator class="absolute left-2 inline-flex items-center">
<Check class="h-4 w-4" />
</ComboboxItemIndicator>
</ComboboxItem>
@@ -168,110 +134,39 @@ const maxEndDate = computed(() => {
</ComboboxGroup>
</ComboboxList>
</Combobox>
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors"></FieldError>
</div>
</FieldContent>
</Field>
</VeeField>
<VeeField v-slot="{ field, errors }" name="type">
<Field class="w-full">
<FieldContent>
<FieldLabel>Type</FieldLabel>
<Select :model-value="field.value" @update:model-value="field.onChange">
<SelectTrigger class="w-full">
<SelectValue placeholder="Select type"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="type in loaTypes" :value="type">
{{ type.name }}
</SelectItem>
</SelectContent>
</Select>
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors"></FieldError>
</div>
</FieldContent>
</Field>
</VeeField>
</div>
<div class="flex gap-5">
<VeeField v-slot="{ field, errors }" name="start_date">
<Field>
<FieldContent>
<FieldLabel>Start Date</FieldLabel>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" :class="cn(
'w-full justify-start text-left font-normal',
!field.value && 'text-muted-foreground',
'w-1/2 justify-start text-left font-normal',
!value && 'text-muted-foreground',
)">
<CalendarIcon class="mr-2 h-4 w-4" />
{{ field.value ? df.format(field.value) : "Pick a date" }}
<template v-if="value.start">
<template v-if="value.end">
{{ df.format(value.start.toDate(getLocalTimeZone())) }} - {{
df.format(value.end.toDate(getLocalTimeZone())) }}
</template>
<template v-else>
{{ df.format(value.start.toDate(getLocalTimeZone())) }}
</template>
</template>
<template v-else>
Pick a date
</template>
</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())" />
<RangeCalendar v-model="value" initial-focus :number-of-months="2"
@update:start-value="(startDate) => value.start = startDate" />
</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>
<Textarea v-model="reason" placeholder="Reason for LOA" class="w-full resize-none" />
<div class="flex justify-end">
<Button type="submit">Submit</Button>
<Button :onClick="handleSubmit" :disabled="submitting" class="w-min">Submit</Button>
</div>
</form>
<div v-if="submitError" class="text-red-500 text-sm mt-2">{{ submitError }}</div>
<div v-if="submitSuccess" class="text-green-500 text-sm mt-2">LOA submitted successfully!</div>
</div>
</div>
</template>

View File

@@ -16,53 +16,30 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
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 { 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[]>([]);
onMounted(async () => {
await loadLOAs();
LOAList.value = await getAllLOAs();
});
async function loadLOAs() {
if (props.adminMode) {
LOAList.value = await getAllLOAs();
} else {
LOAList.value = await getMyLOAs();
}
}
function formatDate(date: Date): string {
if (!date) return "";
date = typeof date === 'string' ? new Date(date) : date;
return date.toLocaleDateString("en-US", {
function formatDate(dateStr: string): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function loaStatus(loa: LOARequest): "Upcoming" | "Active" | "Overdue" | "Closed" {
if (loa.closed) return "Closed";
function loaStatus(loa: {
start_date: string;
end_date: string;
deleted?: number;
}): "Upcoming" | "Active" | "Expired" | "Cancelled" {
if (loa.deleted) return "Cancelled";
const now = new 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 && 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[] {
@@ -81,108 +58,50 @@ function sortByStartDate(loas: LOARequest[]): LOARequest[] {
);
}
async function cancelAndReload(id: number) {
await cancelLOA(id, props.adminMode);
await loadLOAs();
}
const isExtending = ref(false);
const targetLOA = ref<LOARequest | null>(null);
const extendTo = ref<CalendarDate | null>(null);
const targetEnd = computed(() => { return targetLOA.value.extended_till ? targetLOA.value.extended_till : targetLOA.value.end_date })
function toCalendarDate(date: Date): CalendarDate {
if (typeof date === 'string')
date = new Date(date);
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
}
async function commitExtend() {
await extendLOA(targetLOA.value.id, extendTo.value.toDate(getLocalTimeZone()));
isExtending.value = false;
await loadLOAs();
}
const sortedLoas = computed(() => sortByStartDate(LOAList.value));
</script>
<template>
<div>
<Dialog :open="isExtending" @update:open="(val) => isExtending = val">
<DialogContent>
<DialogHeader>
<DialogTitle>Extend {{ targetLOA.name }}'s Leave of Absence </DialogTitle>
</DialogHeader>
<div class="flex gap-5">
<Calendar v-model="extendTo" class="rounded-md border shadow-sm w-min" layout="month-and-year"
:min-value="toCalendarDate(targetEnd)"
:max-value="toCalendarDate(targetEnd).add({ years: 1 })" />
<div class="flex flex-col w-full gap-3 px-2">
<p>Quick Options</p>
<Button variant="outline" @click="extendTo = toCalendarDate(targetEnd).add({ days: 7 })">1
Week</Button>
<Button variant="outline" @click="extendTo = toCalendarDate(targetEnd).add({ months: 1 })">1
Month</Button>
</div>
</div>
<div class="flex justify-end gap-4">
<Button variant="outline" @click="isExtending = false">Cancel</Button>
<Button @click="commitExtend">Extend</Button>
</div>
</DialogContent>
</Dialog>
<div class="max-w-7xl w-full mx-auto">
<div class="w-5xl mx-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Member</TableHead>
<TableHead>Type</TableHead>
<TableHead class="w-[100px]">Member</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead class="w-[500px]">Reason</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Posted on</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="post in LOAList" :key="post.id" class="hover:bg-muted/50">
<TableRow v-for="post in sortedLoas" :key="post.id" class="hover:bg-muted/50">
<TableCell class="font-medium">
{{ post.name }}
</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>{{ 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-if="loaStatus(post) === 'Upcoming'" class="bg-blue-500">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>
<Badge v-else-if="loaStatus(post) === 'Expired'" class="bg-gray-400">Expired</Badge>
<Badge v-else class="bg-red-500">Cancelled</Badge>
</TableCell>
<TableCell @click.stop="" class="text-right">
<TableCell @click.stop="console.log('hi')" 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>
<DropdownMenuItem :variant="'destructive'">Cancel</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</template>

View File

@@ -1,14 +1,7 @@
<script setup>
import { getLocalTimeZone, today } from "@internationalized/date";
import { createReusableTemplate, reactiveOmit, useVModel } from "@vueuse/core";
import { CalendarRoot, useDateFormatter, useForwardPropsEmits } from "reka-ui";
import { createYear, createYearRange, toDate } from "reka-ui/date";
import { computed, toRaw } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { CalendarRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
import {
NativeSelect,
NativeSelectOption,
} from '@/components/ui/native-select';
import {
CalendarCell,
CalendarCellTrigger,
@@ -45,165 +38,34 @@ const props = defineProps({
dir: { type: String, required: false },
nextPage: { 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 },
disableDaysOutsideCurrentView: { 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 },
layout: { type: null, required: false, default: undefined },
yearRange: { type: Array, required: false },
});
const emits = defineEmits(["update:modelValue", "update:placeholder"]);
const delegatedProps = reactiveOmit(props, "class", "layout", "placeholder");
const placeholder = useVModel(props, "placeholder", emits, {
passive: true,
defaultValue: props.defaultPlaceholder ?? today(getLocalTimeZone()),
});
const formatter = useDateFormatter(props.locale ?? "en");
const yearRange = computed(() => {
return (
props.yearRange ??
createYearRange({
start:
props?.minValue ??
(
toRaw(props.placeholder) ??
props.defaultPlaceholder ??
today(getLocalTimeZone())
).cycle("year", -100),
end:
props?.maxValue ??
(
toRaw(props.placeholder) ??
props.defaultPlaceholder ??
today(getLocalTimeZone())
).cycle("year", 10),
})
);
});
const [DefineMonthTemplate, ReuseMonthTemplate] = createReusableTemplate();
const [DefineYearTemplate, ReuseYearTemplate] = createReusableTemplate();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<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
v-slot="{ grid, weekDays, date }"
v-bind="forwarded"
v-model:placeholder="placeholder"
v-slot="{ grid, weekDays }"
data-slot="calendar"
:class="cn('p-3', props.class)"
v-bind="forwarded"
>
<CalendarHeader class="pt-0">
<nav
class="flex items-center gap-1 absolute top-0 inset-x-0 justify-between"
>
<CalendarPrevButton>
<slot name="calendar-prev-icon" />
</CalendarPrevButton>
<CalendarNextButton>
<slot name="calendar-next-icon" />
</CalendarNextButton>
</nav>
<slot
name="calendar-heading"
:date="date"
:month="ReuseMonthTemplate"
:year="ReuseYearTemplate"
>
<template v-if="layout === 'month-and-year'">
<div class="flex items-center justify-center gap-1">
<ReuseMonthTemplate :date="date" />
<ReuseYearTemplate :date="date" />
</div>
</template>
<template v-else-if="layout === 'month-only'">
<div class="flex items-center justify-center gap-1">
<ReuseMonthTemplate :date="date" />
{{ formatter.custom(toDate(date), { year: "numeric" }) }}
</div>
</template>
<template v-else-if="layout === 'year-only'">
<div class="flex items-center justify-center gap-1">
{{ formatter.custom(toDate(date), { month: "short" }) }}
<ReuseYearTemplate :date="date" />
</div>
</template>
<template v-else>
<CalendarHeader>
<CalendarHeading />
</template>
</slot>
<div class="flex items-center gap-1">
<CalendarPrevButton />
<CalendarNextButton />
</div>
</CalendarHeader>
<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({
date: { type: null, required: true },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});

View File

@@ -8,7 +8,7 @@ const props = defineProps({
day: { type: null, required: true },
month: { type: null, required: true },
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 },
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
@@ -19,7 +19,7 @@ const forwardedProps = useForwardProps(delegatedProps);
data-slot="calendar-head-cell"
:class="
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,
)
"

View File

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

View File

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

View File

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

View File

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

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>

View File

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

View File

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

View File

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

View File

@@ -20,12 +20,10 @@ const app = createApp(App)
app.use(createPinia())
app.use(router)
if (import.meta.env.VITE_DISABLE_GLITCHTIP === "true") {
console.log("Glitchtip disabled");
} else {
if (!!import.meta.env.VITE_DISABLE_GLITCHTIP) {
let dsn = import.meta.env.VITE_GLITCHTIP_DSN;
let environment = import.meta.env.VITE_ENVIRONMENT;
let release = import.meta.env.VITE_APPLICATION_VERSION;
Sentry.init({
app,
dsn: dsn,
@@ -34,7 +32,7 @@ if (import.meta.env.VITE_DISABLE_GLITCHTIP === "true") {
],
tracesSampleRate: 0.01,
environment: environment,
release: release
release: 'release tag'
});
}

View File

@@ -2,16 +2,14 @@
import ApplicationChat from '@/components/application/ApplicationChat.vue';
import ApplicationForm from '@/components/application/ApplicationForm.vue';
import { onMounted, ref } from 'vue';
import { approveApplication, denyApplication, loadApplication, postApplication, postChatMessage, getMyApplication, postAdminChatMessage } from '@/api/application';
import { ApplicationData, approveApplication, denyApplication, loadApplication, postApplication, postChatMessage, ApplicationStatus } from '@/api/application';
import { useRoute } from 'vue-router';
import Button from '@/components/ui/button/Button.vue';
import { CheckIcon, XIcon } from 'lucide-vue-next';
import Unauthorized from './Unauthorized.vue';
import { ApplicationData, ApplicationFull, ApplicationStatus, CommentRow } from '@shared/types/application';
const appData = ref<ApplicationData>(null);
const appID = ref<number | null>(null);
const chatData = ref<CommentRow[]>([])
const chatData = ref<object[]>([])
const readOnly = ref<boolean>(false);
const newApp = ref<boolean>(null);
const status = ref<ApplicationStatus>(null);
@@ -21,12 +19,13 @@ const loading = ref<boolean>(true);
const member_name = ref<string>();
const props = defineProps<{
mode?: "create" | "view-self" | "view-recruiter" | "view-self-id"
mode?: "create" | "view-self" | "view-recruiter"
}>()
const finalMode = ref<"create" | "view-self" | "view-recruiter" | "view-self-id">("create");
const finalMode = ref<"create" | "view-self" | "view-recruiter">("create");
function loadData(raw: ApplicationFull) {
async function loadByID(id: number | string) {
const raw = await loadApplication(id);
const data = raw.application;
@@ -41,20 +40,20 @@ function loadData(raw: ApplicationFull) {
readOnly.value = true;
}
const route = useRoute();
const unauthorized = ref(false);
const router = useRoute();
onMounted(async () => {
//recruiter mode
if (props.mode === 'view-recruiter') {
finalMode.value = 'view-recruiter';
loadData(await loadApplication(Number(route.params.id), true))
await loadByID(Number(router.params.id));
}
//viewer mode
if (props.mode === 'view-self') {
finalMode.value = 'view-self';
loadData(await loadApplication("me"))
await loadByID('me');
}
//creator mode
@@ -65,33 +64,40 @@ onMounted(async () => {
newApp.value = true;
}
if (props.mode === 'view-self-id') {
finalMode.value = 'view-self-id';
try {
let raw = await getMyApplication(Number(route.params.id))
loadData(raw);
unauthorized.value = false;
} catch (error) {
if (error.message === "Unauthorized") {
unauthorized.value = true;
} else {
console.error(error);
}
}
}
loading.value = false;
// try {
// //get app ID from URL param
// if (appIDRaw === undefined) {
// //new app
// appData.value = null
// readOnly.value = false;
// newApp.value = true;
// } else {
// //load app
// const raw = await loadApplication(appIDRaw.toString());
// const data = raw.application;
// appID.value = data.id;
// appData.value = data.app_data;
// chatData.value = raw.comments;
// status.value = data.app_status;
// decisionDate.value = new Date(data.decision_at);
// submitDate.value = data.submitted_at ? new Date(data.submitted_at) : null;
// member_name.value = data.member_name;
// newApp.value = false;
// readOnly.value = true;
// }
// } catch (e) {
// console.error(e);
// }
})
async function postComment(comment) {
chatData.value.push(await postChatMessage(comment, appID.value));
}
async function postCommentInternal(comment) {
chatData.value.push(await postAdminChatMessage(comment, appID.value));
}
const emit = defineEmits(['submit']);
async function postApp(appData) {
@@ -116,10 +122,6 @@ async function handleDeny(id) {
<template>
<div v-if="!loading" class="w-full h-20">
<div v-if="unauthorized" class="flex justify-center w-full my-10">
You do not have permission to view this application.
</div>
<div v-else>
<div v-if="!newApp" class="flex flex-row justify-between items-center py-2 mb-8">
<!-- Application header -->
<div>
@@ -162,14 +164,11 @@ async function handleDeny(id) {
</div>
<ApplicationForm :read-only="readOnly" :data="appData" @submit="(e) => { postApp(e) }" class="mb-7">
</ApplicationForm>
<div v-if="!newApp" class="pb-15">
<div v-if="!newApp">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight mb-4">Discussion</h3>
<ApplicationChat :messages="chatData" @post="postComment" @post-internal="postCommentInternal">
</ApplicationChat>
<ApplicationChat :messages="chatData" @post="postComment"></ApplicationChat>
</div>
</div>
</div>
<!-- TODO: Implement some kinda loading screen -->
<div v-else class="flex items-center justify-center h-full">Loading</div>
</template>

View File

@@ -3,14 +3,15 @@ import { ref, watch, nextTick, computed, onMounted } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import { ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next'
import { X, Clock, MapPin, User, ListTodo, ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next'
import CreateCalendarEvent from '@/components/calendar/CreateCalendarEvent.vue'
import { CalendarEvent } from '@shared/types/calendar'
import { getCalendarEvent, getMonthCalendarEvents } from '@/api/calendar'
import { CalendarEvent, CalendarEventShort } from '@shared/types/calendar'
import { Calendar } from '@fullcalendar/core'
import ViewCalendarEvent from '@/components/calendar/ViewCalendarEvent.vue'
import { useRouter, useRoute } from 'vue-router'
import { useCalendarEvents } from '@/composables/useCalendarEvents'
import { useCalendarNavigation } from '@/composables/useCalendarNavigation'
import { useUserStore } from '@/stores/user'
const monthLabels = [
'January', 'February', 'March', 'April', 'May', 'June',
@@ -23,14 +24,13 @@ function api() {
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
function buildFullDate(month: number, year: number): Date {
return new Date(year, month, 1); //default to first of month
}
const { selectedMonth, selectedYear, years, goPrev, goNext, goToday, onDatesSet, goToSelectedDate } = useCalendarNavigation(api)
const { events, loadEvents } = useCalendarEvents(selectedMonth, selectedYear);
const { events, loadEvents} = useCalendarEvents(selectedMonth, selectedYear);
const panelOpen = ref(false)
const activeEvent = ref<CalendarEvent | null>(null)
@@ -48,7 +48,6 @@ const dialogRef = ref<any>(null)
// NEW: handle day/time slot clicks to start creating an event
function onDateClick(arg: { dateStr: string }) {
if (!userStore.isLoggedIn) return;
dialogRef.value?.openDialog(arg.dateStr);
// For now, just open the panel with a draft payload.
// activeEvent.value = {
@@ -203,7 +202,7 @@ onMounted(() => {
@click="goToday">
Today
</button>
<button v-if="userStore.isLoggedIn"
<button
class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
@click="onCreateEvent">
<Plus class="h-4 w-4" />
@@ -217,8 +216,7 @@ onMounted(() => {
<aside v-if="panelOpen"
class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col overflow-auto scrollbar-themed"
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
<ViewCalendarEvent ref="eventViewRef" @close="() => { router.push('/calendar'); }"
@reload="loadEvents()" @edit="(val) => { dialogRef.openDialog(null, 'edit', val) }">
<ViewCalendarEvent ref="eventViewRef" @close="() => { router.push('/calendar'); }" @reload="loadEvents()" @edit="(val) => {dialogRef.openDialog(null, 'edit', val)}">
</ViewCalendarEvent>
</aside>
</div>

View File

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

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

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

View File

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

View File

@@ -1,6 +1,5 @@
<script setup>
import { getAllApplications, approveApplication, denyApplication } from '@/api/application';
import { ApplicationStatus } from '@shared/types/application'
import { getAllApplications, approveApplication, denyApplication, ApplicationStatus } from '@/api/application';
import {
Table,
TableBody,

View File

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

View File

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

View File

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

View File

@@ -6,10 +6,9 @@ const router = createRouter({
routes: [
// PUBLIC
{ path: '/join', component: () => import('@/pages/Join.vue') },
{ path: '/applications', component: () => import('@/pages/MyApplications.vue'), meta: { requiresAuth: true } },
{ path: '/applications/:id', component: () => import('@/pages/MyApplications.vue'), meta: { requiresAuth: true } },
// AUTH REQUIRED
{ path: '/apply', component: () => import('@/pages/Application.vue'), meta: { requiresAuth: true } },
{ path: '/', component: () => import('@/pages/Homepage.vue') },
// MEMBER ROUTES
@@ -17,17 +16,16 @@ const router = createRouter({
{ path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/transfer', component: () => import('@/pages/Transfer.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/calendar', component: () => import('@/pages/Calendar.vue'), meta: { requiresAuth: true, memberOnly: true }, },
{ path: '/calendar/event/:id', component: () => import('@/pages/Calendar.vue'), meta: { requiresAuth: true, memberOnly: true }, },
// disabled in favor of linking
// { path: '/documents', component: () => import('@/pages/Documentation.vue'), meta: { requiresAuth: true, memberOnly: true }, },
{ path: '/trainingReport', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/trainingReport/new', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/trainingReport/:id', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
// Personnel File
{ path: '/dossier', component: () => import('@/pages/Dossier.vue'), meta: { requiresAuth: false, memberOnly: false } },
// ADMIN / STAFF ROUTES
{
path: '/administration',