diff --git a/.gitea/workflows/cd-deploy.yaml b/.gitea/workflows/cd-deploy.yaml index 66e64f1..98e5593 100644 --- a/.gitea/workflows/cd-deploy.yaml +++ b/.gitea/workflows/cd-deploy.yaml @@ -1,6 +1,8 @@ name: Continuous Deployment on: push: + tags: + - '*' jobs: Deploy: @@ -8,20 +10,29 @@ jobs: runs-on: ubuntu-latest container: volumes: - - /var/www/html/milsim-site-v4:/var/www/html/milsim-site-v4:rw + - /var/www/html/milsim-site-v4:/var/www/html/milsim-site-v4:z steps: - name: Setup Local Environment run: | groupadd -g 989 nginx || true useradd nginx -u 990 -g nginx -m || true - - name: Verify Node Environment + - 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@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 ref: 'main' @@ -32,31 +43,53 @@ jobs: cp /workspace/17th-Ranger-Battalion-ORG/milsim-site-v4/.git/config .git/config chown nginx:nginx .git/config - - name: Fix File Permissions - run: | - sudo chown -R nginx:nginx /var/www/html/milsim-site-v4 - sudo chmod -R u+w /var/www/html/milsim-site-v4 - - name: Update Application Code run: | - sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4 && git reset --hard && git pull origin main" + 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 + - name: Update Shared Dependencies and Fix Permissions run: | - sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/shared && npm install" + cd /var/www/html/milsim-site-v4/shared + npm install + chown -R nginx:nginx . - - name: Update UI Dependencies + - name: Update UI Dependencies and Fix Permissions run: | - sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/ui && npm install" + cd /var/www/html/milsim-site-v4/ui + npm install + chown -R nginx:nginx . - - name: Update API Dependencies + - name: Update API Dependencies and Fix Permissions run: | - sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/api && npm install" + cd /var/www/html/milsim-site-v4/api + npm install + chown -R nginx:nginx . - - name: Build UI + - name: Build UI / Update Version / Fix Permissions run: | - sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/ui && npm run build" + 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 + - name: Build API / Update Version / Fix Permissions run: | - sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/api && npm run build" + cd /var/www/html/milsim-site-v4/api + npm run build + version=`git describe --abbrev=0 --tags` + sed -i "s/APPLICATION_VERSION=.*/APPLICATION_VERSION=$version/" .env + chown -R nginx:nginx . + + - name: Reset File Permissions + run: | + sudo chown -R nginx:nginx /var/www/html/milsim-site-v4 + sudo chmod -R u+w /var/www/html/milsim-site-v4 \ No newline at end of file diff --git a/.gitea/workflows/ci-deploy.yaml b/.gitea/workflows/ci-deploy.yaml new file mode 100644 index 0000000..95ebb3d --- /dev/null +++ b/.gitea/workflows/ci-deploy.yaml @@ -0,0 +1,89 @@ +name: Continuous Integration +on: + push: + branches: + - main + +jobs: + Deploy: + name: Update Development + runs-on: ubuntu-latest + container: + volumes: + - /var/www/html/milsim-site-v4-dev:/var/www/html/milsim-site-v4:z + steps: + - name: Setup Local Environment + run: | + groupadd -g 989 nginx || true + useradd nginx -u 990 -g nginx -m || true + + - name: Update Node Environment + uses: actions/setup-node@v6 + with: + node-version: 20.19 + + - name: Verify Local Environment + run: | + which npm + npm -v + which node + node -v + which sed + sed --version + + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: 'main' + + - name: Token Copy + run: | + cd /var/www/html/milsim-site-v4 + cp /workspace/17th-Ranger-Battalion-ORG/milsim-site-v4/.git/config .git/config + chown nginx:nginx .git/config + + - name: Update Application Code + run: | + cd /var/www/html/milsim-site-v4 + sudo -u nginx git reset --hard + sudo -u nginx git pull origin main + + - name: Update Shared Dependencies and Fix Permissions + run: | + cd /var/www/html/milsim-site-v4/shared + npm install + chown -R nginx:nginx . + + - name: Update UI Dependencies and Fix Permissions + run: | + cd /var/www/html/milsim-site-v4/ui + npm install + chown -R nginx:nginx . + + - name: Update API Dependencies and Fix Permissions + run: | + cd /var/www/html/milsim-site-v4/api + npm install + chown -R nginx:nginx . + + - name: Build UI / Update Version / Fix Permissions + run: | + cd /var/www/html/milsim-site-v4/ui + npm run build + version=`git rev-parse --short=10 HEAD` + sed -i "s/VITE_APPLICATION_VERSION=.*/VITE_APPLICATION_VERSION=$version/" .env + chown -R nginx:nginx . + + - name: Build API / Update Version / Fix Permissions + run: | + cd /var/www/html/milsim-site-v4/api + npm run build + version=`git rev-parse --short=10 HEAD` + sed -i "s/APPLICATION_VERSION=.*/APPLICATION_VERSION=$version/" .env + chown -R nginx:nginx . + + - name: Reset File Permissions + run: | + sudo chown -R nginx:nginx /var/www/html/milsim-site-v4 + sudo chmod -R u+w /var/www/html/milsim-site-v4 \ No newline at end of file diff --git a/api/.env.example b/api/.env.example index a740a31..8bfe497 100644 --- a/api/.env.example +++ b/api/.env.example @@ -19,7 +19,14 @@ 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 \ No newline at end of file +DISABLE_GLITCHTIP= # true/false + +# Bookstack +DOC_HOST= # https://bookstack.whatever.com/ +DOC_TOKEN_SECRET= +DOC_TOKEN_ID= \ No newline at end of file diff --git a/api/src/index.js b/api/src/index.js index 68d2f1f..00bf5cc 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -20,11 +20,14 @@ const port = process.env.SERVER_PORT; //glitchtip setup const sentry = require('@sentry/node'); -if (!process.env.DISABLE_GLITCHTIP) { - console.log("Glitchtip disabled AAAAAA") +if (process.env.DISABLE_GLITCHTIP === "true") { + console.log("Glitchtip disabled") } else { let dsn = process.env.GLITCHTIP_DSN; - sentry.init({ dsn: dsn }); + let release = process.env.APPLICATION_VERSION; + let environment = process.env.APPLICATION_ENVIRONMENT; + console.log(release, environment) + sentry.init({ dsn: dsn, release: release, environment: environment }); console.log("Glitchtip initialized"); } @@ -58,6 +61,7 @@ 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); diff --git a/api/src/routes/applications.ts b/api/src/routes/applications.ts index 76c0eca..c79514e 100644 --- a/api/src/routes/applications.ts +++ b/api/src/routes/applications.ts @@ -2,11 +2,13 @@ const express = require('express'); const router = express.Router(); import pool from '../db'; -import { approveApplication, createApplication, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService'; +import { approveApplication, createApplication, denyApplication, getAllMemberApplications, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService'; import { MemberState, setUserState } from '../services/memberService'; import { 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) => { @@ -16,13 +18,13 @@ router.post('/', async (req, res) => { const appVersion = 1; - createApplication(memberID, appVersion, JSON.stringify(App)) - setUserState(memberID, MemberState.Applicant); + await createApplication(memberID, appVersion, JSON.stringify(App)) + await setUserState(memberID, MemberState.Applicant); res.sendStatus(201); } catch (err) { - console.error('Insert failed:', err); - res.status(500).json({ error: 'Failed to save application' }); + console.error('Failed to create application: \n', err); + res.status(500).json({ error: 'Failed to create application' }); } }); @@ -37,6 +39,20 @@ 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; @@ -62,15 +78,55 @@ router.get('/me', async (req, res) => { }) // GET /application/:id -router.get('/:id', async (req, res) => { - let appID = req.params.id; - console.log("HELLO") +router.get('/me/:id', async (req: Request, res: Response) => { + let appID = Number(req.params.id); + let member = req.user.id; + 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); + + 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' }); + } +}); + +// 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); + const comments: CommentRow[] = await getApplicationComments(appID, asAdmin); const output: ApplicationFull = { application, @@ -92,9 +148,6 @@ 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"); @@ -119,26 +172,11 @@ 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 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); - } - + const app = await getApplicationByID(appID); + await denyApplication(appID); + await setUserState(app.member_id, MemberState.Denied); + res.sendStatus(200); } catch (err) { console.error('Approve failed:', err); res.status(500).json({ error: 'Failed to deny application' }); @@ -146,10 +184,12 @@ router.post('/deny/:id', async (req, res) => { }); // POST /application/:id/comment -router.post('/:id/comment', async (req, res) => { +router.post('/:id/comment', async (req: Request, res: Response) => { const appID = req.params.id; const data = req.body.message; - const user = 1; + const user = req.user; + + console.log(user) const sql = `INSERT INTO application_comments( application_id, @@ -161,7 +201,7 @@ VALUES(?, ?, ?);` try { const conn = await pool.getConnection(); - const result = await conn.query(sql, [appID, user, data]) + const result = await conn.query(sql, [appID, user.id, data]) console.log(result) if (result.affectedRows !== 1) { conn.release(); @@ -186,4 +226,60 @@ 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; diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js index c0a089b..c736f33 100644 --- a/api/src/routes/auth.js +++ b/api/src/routes/auth.js @@ -21,12 +21,13 @@ 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('id_token claims:', JSON.stringify(jwtClaims, null, 2)); - // console.log('preferred_username:', jwtClaims?.preferred_username); + console.log('profile:', profile); + console.log('id_token claims:', JSON.stringify(jwtClaims, null, 2)); + console.log('preferred_username:', jwtClaims?.preferred_username); const con = await pool.getConnection(); try { diff --git a/api/src/routes/loa.js b/api/src/routes/loa.js deleted file mode 100644 index c14bf24..0000000 --- a/api/src/routes/loa.js +++ /dev/null @@ -1,56 +0,0 @@ -const express = require('express'); -const router = express.Router(); - -import pool from '../db'; - -//post a new LOA -router.post("/", async (req, res) => { - const { member_id, filed_date, start_date, end_date, reason } = req.body; - - if (!member_id || !filed_date || !start_date || !end_date) { - return res.status(400).json({ error: "Missing required fields" }); - } - - try { - const result = await pool.query( - `INSERT INTO leave_of_absences - (member_id, filed_date, start_date, end_date, reason) - VALUES (?, ?, ?, ?, ?)`, - [member_id, filed_date, start_date, end_date, reason] - ); - res.sendStatus(201); - } catch (error) { - console.error(error); - res.status(500).send('Something went wrong', error); - } -}); - -//get my current LOA -router.get("/me", async (req, res) => { - //TODO: implement current user getter - const user = 89; - - try { - const result = await pool.query("SELECT * FROM leave_of_absences WHERE member_id = ?", [user]) - res.status(200).json(result) - } catch (error) { - console.error(error); - res.status(500).send(error); - } -}) - -router.get('/all', async (req, res) => { - try { - const result = await pool.query( - `SELECT loa.*, members.name - FROM leave_of_absences AS loa - INNER JOIN members ON loa.member_id = members.id; - `); - res.status(200).json(result) - } catch (error) { - console.error(error); - res.status(500).send(error); - } -}) - -module.exports = router; diff --git a/api/src/routes/loa.ts b/api/src/routes/loa.ts new file mode 100644 index 0000000..cbcc30b --- /dev/null +++ b/api/src/routes/loa.ts @@ -0,0 +1,148 @@ +const express = require('express'); +const router = express.Router(); + +import { Request, Response } from 'express'; +import pool from '../db'; +import { closeLOA, createNewLOA, getAllLOA, getLOAbyID, getLoaTypes, getUserLOA, setLOAExtension } from '../services/loaService'; +import { LOARequest } from '@app/shared/types/loa'; + +//member posts LOA +router.post("/", async (req: Request, res: Response) => { + let LOARequest = req.body as LOARequest; + LOARequest.member_id = req.user.id; + LOARequest.created_by = req.user.id; + LOARequest.filed_date = new Date(); + + try { + await createNewLOA(LOARequest); + res.sendStatus(201); + } catch (error) { + console.error(error); + res.status(500).send(error); + } +}); + +//admin posts LOA +router.post("/admin", async (req: Request, res: Response) => { + let LOARequest = req.body as LOARequest; + LOARequest.created_by = req.user.id; + LOARequest.filed_date = new Date(); + + console.log(LOARequest); + + try { + await createNewLOA(LOARequest); + res.sendStatus(201); + } catch (error) { + console.error(error); + res.status(500).send(error); + } +}); + +//get my current LOA +router.get("/me", async (req: Request, res: Response) => { + const user = req.user.id; + try { + const result = await getUserLOA(user); + res.status(200).json(result) + } catch (error) { + console.error(error); + res.status(500).send(error); + } +}) + +//get my LOA history +router.get("/history", async (req: Request, res: Response) => { + const user = req.user.id; + try { + const result = await getUserLOA(user); + res.status(200).json(result) + } catch (error) { + console.error(error); + res.status(500).send(error); + } +}) + +router.get('/all', async (req, res) => { + try { + const result = await getAllLOA(); + res.status(200).json(result) + } catch (error) { + console.error(error); + res.status(500).send(error); + } +}) + +router.get('/types', async (req: Request, res: Response) => { + try { + let out = await getLoaTypes(); + res.status(200).json(out); + } catch (error) { + res.status(500).json(error); + console.error(error); + } +}) + +router.post('/cancel/:id', async (req: Request, res: Response) => { + let closer = req.user.id; + let id = Number(req.params.id); + try { + let loa = await getLOAbyID(id); + if (loa.member_id != closer) { + return res.sendStatus(403); + } + + await closeLOA(Number(req.params.id), closer); + res.sendStatus(200); + } catch (error) { + console.error(error); + res.status(500).json(error); + } +}) + +//TODO: enforce admin only +router.post('/adminCancel/:id', async (req: Request, res: Response) => { + let closer = req.user.id; + try { + await closeLOA(Number(req.params.id), closer); + res.sendStatus(200); + } catch (error) { + console.error(error); + res.status(500).json(error); + } +}) + +// TODO: Enforce admin only +router.post('/extend/:id', async (req: Request, res: Response) => { + const to: Date = req.body.to; + + if (!to) { + res.status(400).send("Extension length is required"); + } + + try { + await setLOAExtension(Number(req.params.id), to); + res.sendStatus(200); + } catch (error) { + console.error(error) + res.status(500).json(error); + } +}) + +router.get('/policy', async (req: Request, res: Response) => { + const output = await fetch(`${process.env.DOC_HOST}/api/pages/42`, { + headers: { + Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`, + } + }) + + if (output.ok) { + const out = await output.json(); + res.status(200).json(out.html); + } else { + console.error("Failed to fetch LOA policy from bookstack"); + res.sendStatus(500); + } +}) + +module.exports = router; diff --git a/api/src/routes/members.js b/api/src/routes/members.js index c93f249..3196569 100644 --- a/api/src/routes/members.js +++ b/api/src/routes/members.js @@ -2,6 +2,7 @@ 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'; @@ -40,12 +41,13 @@ 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 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 roleData = await getUserRoles(req.user.id); diff --git a/api/src/services/applicationService.ts b/api/src/services/applicationService.ts index 8e94a08..dceaad3 100644 --- a/api/src/services/applicationService.ts +++ b/api/src/services/applicationService.ts @@ -1,5 +1,6 @@ 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 (?, ?, ?);`; @@ -12,12 +13,13 @@ export async function getMemberApplication(memberID: number): Promise { const sql = `SELECT app.*, @@ -44,7 +46,20 @@ export async function getApplicationList(): Promise { return rows; } -export async function approveApplication(id) { +export async function getAllMemberApplications(memberID: number): Promise { + const sql = `SELECT + app.id, + app.member_id, + app.submitted_at, + app.app_status + FROM applications AS app WHERE app.member_id = ? ORDER BY submitted_at DESC;`; + + const rows: ApplicationListRow[] = await pool.query(sql, [memberID]) + return rows; +} + + +export async function approveApplication(id: number) { const sql = ` UPDATE applications SET approved_at = NOW() @@ -57,15 +72,38 @@ export async function approveApplication(id) { return result; } -export async function getApplicationComments(appID: number): Promise { +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 { + const excludeAdmin = ' AND app.admin_only = false'; + + const whereClause = `WHERE app.application_id = ?${!admin ? excludeAdmin : ''}`; + return await pool.query(`SELECT app.id AS comment_id, 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.application_id = ?;`, + ${whereClause}`, [appID]); } \ No newline at end of file diff --git a/api/src/services/calendarService.ts b/api/src/services/calendarService.ts index c88f671..7aa2cee 100644 --- a/api/src/services/calendarService.ts +++ b/api/src/services/calendarService.ts @@ -123,15 +123,9 @@ export async function setAttendanceStatus(memberID: number, eventID: number, sta } export async function getEventAttendance(eventID: number): Promise { - const sql = ` - SELECT - s.member_id, - s.status, - m.name AS member_name - FROM calendar_events_signups s - LEFT JOIN members m ON s.member_id = m.id - WHERE s.event_id = ? - `; - return await pool.query(sql, [eventID]); + const sql = "CALL `sp_GetCalendarEventSignups`(?)" + const res = await pool.query(sql, [eventID]); + console.log(res[0]); + return res[0]; } \ No newline at end of file diff --git a/api/src/services/loaService.ts b/api/src/services/loaService.ts new file mode 100644 index 0000000..19e789e --- /dev/null +++ b/api/src/services/loaService.ts @@ -0,0 +1,98 @@ +import { toDateTime } from "@app/shared/utils/time"; +import pool from "../db"; +import { LOARequest, LOAType } from '@app/shared/types/loa' + +export async function getLoaTypes(): Promise { + return await pool.query('SELECT * FROM leave_of_absences_types;'); +} + +export async function getAllLOA(page = 1, pageSize = 20): Promise { + 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 { + 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 { + 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 { + 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]; +} \ No newline at end of file diff --git a/ecosystem.config.js b/ecosystem.config.js index 68925cc..aa2c2ed 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -5,6 +5,7 @@ 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 diff --git a/shared/schemas/loaSchema.ts b/shared/schemas/loaSchema.ts new file mode 100644 index 0000000..30c94c2 --- /dev/null +++ b/shared/schemas/loaSchema.ts @@ -0,0 +1,51 @@ +import * as z from "zod"; +import { LOAType } from "../types/loa"; + +export const loaTypeSchema = z.object({ + id: z.number(), + name: z.string(), + max_length_days: z.number(), +}); + +export const loaSchema = z.object({ + member_id: z.number(), + start_date: z.date(), + end_date: z.date(), + type: loaTypeSchema, + reason: z.string(), +}) + .superRefine((data, ctx) => { + const { start_date, end_date, type } = data; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (start_date < today) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["start_date"], + message: "Start date cannot be in the past.", + }); + } + + // 1. end > start + if (end_date <= start_date) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["end_date"], + message: "End date must be after start date.", + }); + } + + // 2. calculate max + const maxEnd = new Date(start_date); + maxEnd.setDate(maxEnd.getDate() + type.max_length_days); + + if (end_date > maxEnd) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["end_date"], + message: `This LOA type allows a maximum of ${type.max_length_days} days.`, + }); + } + }); diff --git a/shared/types/application.ts b/shared/types/application.ts index f8b648d..12b0db5 100644 --- a/shared/types/application.ts +++ b/shared/types/application.ts @@ -40,6 +40,7 @@ export interface CommentRow { post_time: string; last_modified: string | null; poster_name: string; + admin_only: boolean; } export interface ApplicationFull { diff --git a/shared/types/calendar.ts b/shared/types/calendar.ts index 526068f..5a62a8b 100644 --- a/shared/types/calendar.ts +++ b/shared/types/calendar.ts @@ -26,6 +26,7 @@ export interface CalendarSignup { eventID: number; status: CalendarAttendance; member_name?: string; + member_unit?: string; } export interface CalendarEventShort { diff --git a/shared/types/loa.ts b/shared/types/loa.ts new file mode 100644 index 0000000..4eae0dc --- /dev/null +++ b/shared/types/loa.ts @@ -0,0 +1,24 @@ +export interface LOARequest { + id?: number; + member_id?: number; + filed_date?: Date; // ISO 8601 string + start_date: Date; // ISO 8601 string + end_date: Date; // ISO 8601 string + extended_till?: Date; + type_id?: number; + reason?: string; + expired?: boolean; + closed?: boolean; + closed_by?: number; + created_by?: number; + + name?: string; //member name + type_name?: string; +}; + +export interface LOAType { + id: number; + name: string; + max_length_days: number; + extendable: boolean; +} \ No newline at end of file diff --git a/shared/utils/time.ts b/shared/utils/time.ts index 416fba5..322b015 100644 --- a/shared/utils/time.ts +++ b/shared/utils/time.ts @@ -1,5 +1,8 @@ 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"); diff --git a/ui/.env.example b/ui/.env.example index becdf99..9a0998f 100644 --- a/ui/.env.example +++ b/ui/.env.example @@ -1,6 +1,9 @@ # 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= diff --git a/ui/src/App.vue b/ui/src/App.vue index a656c08..8289a75 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -5,6 +5,7 @@ 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(); @@ -29,10 +30,11 @@ const environment = import.meta.env.VITE_ENVIRONMENT;

This is a development build of the application. Some features will be unavailable or unstable.

- + -

You are on LOA until {{ formatDate(userStore.user?.loa?.[0].end_date) }}

- +

You are on LOA until {{ formatDate(userStore.user?.LOAData?.[0].end_date) }}

+
diff --git a/ui/src/api/application.ts b/ui/src/api/application.ts index 85a3dc5..8f60f1e 100644 --- a/ui/src/api/application.ts +++ b/ui/src/api/application.ts @@ -1,80 +1,11 @@ -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[]; -} +import { ApplicationFull } from "@shared/types/application"; -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): Promise { - const res = await fetch(`${addr}/application/${id}`, { credentials: 'include' }) +export async function loadApplication(id: number | string, asAdmin: boolean = false): Promise { + const res = await fetch(`${addr}/application/${id}?admin=${asAdmin}`, { credentials: 'include' }) if (res.status === 204) return null if (!res.ok) throw new Error('Failed to load application') const json = await res.json() @@ -104,6 +35,22 @@ export async function postChatMessage(message: any, post_id: number) { const response = await fetch(`${addr}/application/${post_id}/comment`, { 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), }) @@ -121,6 +68,26 @@ export async function getAllApplications(): Promise { } } +export async function loadMyApplications(): Promise { + 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 { + 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' }) @@ -135,4 +102,15 @@ export async function denyApplication(id: Number) { if (!res.ok) { 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") + } } \ No newline at end of file diff --git a/ui/src/api/loa.ts b/ui/src/api/loa.ts index 6f9314b..dd7350a 100644 --- a/ui/src/api/loa.ts +++ b/ui/src/api/loa.ts @@ -1,12 +1,4 @@ -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; -}; +import { LOARequest, LOAType } from '@shared/types/loa' // @ts-ignore const addr = import.meta.env.VITE_APIHOST; @@ -17,6 +9,24 @@ export async function submitLOA(request: LOARequest): Promise<{ id?: number; err "Content-Type": "application/json", }, 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) { @@ -26,6 +36,7 @@ export async function submitLOA(request: LOARequest): Promise<{ id?: number; err } } + export async function getMyLOA(): Promise { const res = await fetch(`${addr}/loa/me`, { method: "GET", @@ -60,3 +71,84 @@ export function getAllLOAs(): Promise { } }); } + +export function getMyLOAs(): Promise { + 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 { + 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 { + 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"); + } +} \ No newline at end of file diff --git a/ui/src/assets/base.css b/ui/src/assets/base.css index 0814dcd..b29a3bb 100644 --- a/ui/src/assets/base.css +++ b/ui/src/assets/base.css @@ -165,4 +165,76 @@ body { @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; } \ No newline at end of file diff --git a/ui/src/components/Navigation/Navbar.vue b/ui/src/components/Navigation/Navbar.vue index bb73289..0d777d8 100644 --- a/ui/src/components/Navigation/Navbar.vue +++ b/ui/src/components/Navigation/Navbar.vue @@ -163,6 +163,12 @@ function blurAfter() { Join + + + + Calendar + + @@ -177,6 +183,7 @@ function blurAfter() { My Application + Application History Logout diff --git a/ui/src/components/application/ApplicationChat.vue b/ui/src/components/application/ApplicationChat.vue index cc1d653..2501d50 100644 --- a/ui/src/components/application/ApplicationChat.vue +++ b/ui/src/components/application/ApplicationChat.vue @@ -11,13 +11,18 @@ 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: Array> + messages: CommentRow[] }>() const emit = defineEmits<{ (e: 'post', text: string): void + (e: 'postInternal', text: string): void }>() const commentSchema = toTypedSchema( @@ -26,9 +31,14 @@ const commentSchema = toTypedSchema( }) ) +const submitMode = ref("public"); + // vee-validate passes (values, actions) to @submit function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => void }) { - emit('post', values.text.trim()) + if (submitMode.value === "internal") + emit('postInternal', values.text.trim()) + else + emit('post', values.text.trim()) resetForm() } @@ -48,25 +58,31 @@ function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => vo -
- +
+ +
-
+
-

{{ message.poster_name }}

+
+

{{ message.poster_name }}

+

+ Internal +

+

{{ new Date(message.post_time).toLocaleString("EN-us", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" - }) }}

+ }) }}

{{ message.post_content }}

diff --git a/ui/src/components/application/ApplicationForm.vue b/ui/src/components/application/ApplicationForm.vue index 02b84b4..eb1a85f 100644 --- a/ui/src/components/application/ApplicationForm.vue +++ b/ui/src/components/application/ApplicationForm.vue @@ -16,23 +16,27 @@ import { Form } from 'vee-validate'; import { onMounted, ref } from 'vue'; import * as z from 'zod'; import DateInput from '../form/DateInput.vue'; -import { ApplicationData } from '@/api/application'; +import { ApplicationData } from '@shared/types/application'; + +const regexA = /^https?:\/\/steamcommunity\.com\/id\/[A-Za-z0-9_]+\/?$/; +const regexB = /^https?:\/\/steamcommunity\.com\/profiles\/\d+\/?$/; + const formSchema = toTypedSchema(z.object({ dob: z.string().refine(v => v, { message: "A date of birth is required." }), - name: z.string(), + name: z.string().nonempty(), playtime: z.coerce.number({ invalid_type_error: "Must be a number", }).min(0, "Cannot be less than 0"), - hobbies: z.string(), + hobbies: z.string().nonempty(), military: z.boolean(), - communities: z.string(), - joinReason: z.string(), - milsimAttraction: z.string(), - referral: z.string(), - steamProfile: z.string(), - timezone: z.string(), + 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(), canAttendSaturday: z.boolean(), - interests: z.string(), - aknowledgeRules: z.literal(true, { + interests: z.string().nonempty(), + acknowledgeRules: z.literal(true, { errorMap: () => ({ message: "Required" }) }), })) @@ -41,7 +45,7 @@ const formSchema = toTypedSchema(z.object({ const fallbackInitials = { military: false, canAttendSaturday: false, - aknowledgeRules: false, + acknowledgeRules: false, } const props = defineProps<{ @@ -82,7 +86,9 @@ onMounted(() => { - +
+ +
@@ -94,7 +100,9 @@ onMounted(() => { - +
+ +
@@ -105,7 +113,9 @@ onMounted(() => { - +
+ +
@@ -117,7 +127,9 @@ onMounted(() => { +
+ +
+ + + +
+
+ +
+
\ No newline at end of file diff --git a/ui/src/components/loa/loaList.vue b/ui/src/components/loa/loaList.vue index 6494067..2151d1c 100644 --- a/ui/src/components/loa/loaList.vue +++ b/ui/src/components/loa/loaList.vue @@ -16,30 +16,53 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Ellipsis } from "lucide-vue-next"; -import { getAllLOAs, LOARequest } from "@/api/loa"; +import { cancelLOA, extendLOA, getAllLOAs, getMyLOAs } from "@/api/loa"; import { onMounted, ref, computed } from "vue"; +import { 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([]); onMounted(async () => { - LOAList.value = await getAllLOAs(); + await loadLOAs(); }); -function formatDate(dateStr: string): string { - if (!dateStr) return ""; - return new Date(dateStr).toLocaleDateString("en-US", { +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", { year: "numeric", month: "short", day: "numeric", }); } -function loaStatus(loa: { - start_date: string; - end_date: string; - deleted?: number; -}): "Upcoming" | "Active" | "Expired" | "Cancelled" { - if (loa.deleted) return "Cancelled"; +function loaStatus(loa: LOARequest): "Upcoming" | "Active" | "Overdue" | "Closed" { + if (loa.closed) return "Closed"; const now = new Date(); const start = new Date(loa.start_date); @@ -47,9 +70,9 @@ function loaStatus(loa: { if (now < start) return "Upcoming"; if (now >= start && now <= end) return "Active"; - if (now > end) return "Expired"; + if (now > end) return "Overdue"; - return "Expired"; // fallback + return "Overdue"; // fallback } function sortByStartDate(loas: LOARequest[]): LOARequest[] { @@ -58,50 +81,108 @@ function sortByStartDate(loas: LOARequest[]): LOARequest[] { ); } -const sortedLoas = computed(() => sortByStartDate(LOAList.value)); +async function cancelAndReload(id: number) { + await cancelLOA(id, props.adminMode); + await loadLOAs(); +} + +const isExtending = ref(false); +const targetLOA = ref(null); +const extendTo = ref(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(); +} diff --git a/ui/src/components/ui/calendar/Calendar.vue b/ui/src/components/ui/calendar/Calendar.vue index e3f69e2..3d421ec 100644 --- a/ui/src/components/ui/calendar/Calendar.vue +++ b/ui/src/components/ui/calendar/Calendar.vue @@ -1,7 +1,14 @@