60 Commits

Author SHA1 Message Date
e7b73f9e73 Calendar Integration 2025-12-13 01:36:17 -05:00
533e315642 Application integration 2025-12-13 01:36:12 -05:00
93e8f3b3d2 LOA integration 2025-12-13 01:24:25 -05:00
82eb6b7bbf Added displayname and member card system 2025-12-13 01:21:07 -05:00
8aad3c67c7 added profile nav options 2025-12-12 12:53:14 -05:00
445c15b797 Merge pull request 'database-view-updates' (#67) from database-view-updates into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m17s
Continuous Deployment / Update Deployment (push) Successful in 2m10s
Reviewed-on: #67
2025-12-12 10:27:18 -06:00
406f61a612 Merge branch 'main' into database-view-updates 2025-12-12 10:24:01 -06:00
f7daa1bb19 Merge pull request 'application-dp-upgrade' (#66) from application-dp-upgrade into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m35s
Reviewed-on: #66
2025-12-12 10:16:56 -06:00
8a7ca9c2f9 Merge branch 'main' into application-dp-upgrade 2025-12-12 10:12:10 -06:00
1730714737 updated loa bookstack to new render system 2025-12-12 11:10:32 -05:00
8dc11ee34d made nested bookstack links open in new tab 2025-12-12 11:06:00 -05:00
2a841ebf27 fixed "view coc" button causing application submit 2025-12-12 10:56:07 -05:00
d7908570b2 improved bookstack rendering 2025-12-12 10:53:45 -05:00
629fd59a7c fixed scrolling behaviour on application management page 2025-12-12 10:53:33 -05:00
333bf20d86 integrated CoC pull from bookstack 2025-12-12 00:39:19 -05:00
bd3c3564a9 Merge pull request 'Documentation' (#65) from Documentation into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m23s
Reviewed-on: #65
2025-12-11 22:00:45 -06:00
6c688a86ea Merge commit 'a6d54e0b73b0e388a96008400977db2715f214ce' into Documentation 2025-12-11 22:49:57 -05:00
26ccae9078 added arrow to indicate redirect
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m29s
2025-12-11 22:36:20 -05:00
dc88126b6c Added documents site link
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m36s
2025-12-11 22:26:33 -05:00
c74b5b280b placeholdered CoC getter until CoC page exists on bookstack 2025-12-11 22:05:46 -05:00
0a1704b60b Updated application acceptance system 2025-12-11 21:43:49 -05:00
97119dec97 added config_id reference to .env 2025-12-11 21:22:23 -05:00
a6d54e0b73 Merge pull request '#54-Application-tweaks' (#62) from #54-Application-tweaks into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m8s
Reviewed-on: #62
2025-12-11 19:54:02 -06:00
79d80be1bf Merge commit '0919997e0f90ec5463f63f470213acd7e39493af' into #54-Application-tweaks 2025-12-11 20:55:03 -05:00
0919997e0f Merge pull request 'LOA-Upgrades' (#64) from LOA-Upgrades into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m32s
Reviewed-on: #64
2025-12-11 19:50:14 -06:00
a5d4941d00 Merge branch 'main' into LOA-Upgrades 2025-12-11 19:49:55 -06:00
3835f22940 removed nuisance logging 2025-12-11 20:41:29 -05:00
f0ac2dca02 fixed an issue with a bad response on submitting LOA 2025-12-11 20:37:00 -05:00
710b24e5a7 added policy system and self LOA management 2025-12-11 20:28:49 -05:00
bcde81093d implemented LOA cancelling and extensioning 2025-12-11 19:08:24 -05:00
a3216ba5ab More bookstack integration stuff 2025-12-11 15:49:08 -05:00
7ab06b6a4c hooked up new LOA creation system 2025-12-11 12:06:00 -05:00
9d217aafaf improved robustness of time serialization system 2025-12-11 12:05:41 -05:00
87c472e98e fixed dialog scaling issue 2025-12-11 11:52:11 -05:00
dcb4720129 placeholdered bookstack integration 2025-12-11 11:38:11 -05:00
8cdbb99d6f implemented constraints on datepickers 2025-12-11 11:25:09 -05:00
2821bc62c4 removed test datepicker 2025-12-11 11:00:11 -05:00
dd472a5283 updated datepicker dependency 2025-12-11 11:00:02 -05:00
92c0d657ea overhauled form system to new modern form 2025-12-11 10:25:29 -05:00
7a6020febb Changed event_date to start_date 2025-12-11 09:22:44 -06:00
fb64b35807 Updated database reference view_member_rank_status_all to view_member_rank_unit_status_latest 2025-12-10 22:18:52 -06:00
a8165e2ae5 Added Unit Table Header and Cell 2025-12-10 22:18:08 -06:00
79cf77dc63 Added 2 data types unit, unit_date 2025-12-10 22:17:45 -06:00
62defe5b6d LOA backend refactor 2025-12-10 21:30:41 -05:00
065d3f330a Merge pull request 'Updated GUILDED references and links.' (#60) from external-links-and-app-messages into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 1m56s
Continuous Deployment / Update Deployment (push) Successful in 2m9s
Reviewed-on: #60
2025-12-10 20:21:32 -06:00
dcd1f522ad Merge branch 'main' into external-links-and-app-messages 2025-12-10 20:21:24 -06:00
22c909cfa5 increase logging in deploy script
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m24s
for some reason it seems to not be fully updating the local repo, so increase logging and double pull
2025-12-10 20:20:06 -06:00
5354fa85f1 minor wording change to submit buttons
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m28s
2025-12-09 17:02:55 -05:00
f5a0df7795 Supported public vs internal application comments, and moved some type dependencies to the shared lib 2025-12-09 17:02:39 -05:00
e22f164097 Handled retired behaviour on join page
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m21s
2025-12-08 22:32:07 -05:00
ae9c7c89b1 fix acknowledge spelling
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m23s
2025-12-08 22:06:04 -05:00
dab0a7543c added steam regex support 2025-12-08 22:03:11 -05:00
63267ac679 set up viewing of users application history
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m22s
2025-12-08 21:28:02 -05:00
4a65596283 tweaked get app query to support multiple applications
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m23s
2025-12-08 19:29:59 -05:00
9720ee6ada Updated GUILDED references and links.
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m22s
2025-12-08 17:55:33 -06:00
e61bd1c5a1 Enabled restarting your application from denied state
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m23s
2025-12-08 17:10:53 -05:00
df89d9bf67 fixed some missing awaits 2025-12-08 16:24:00 -05:00
4ab803ec72 Fixed hardcoded value in application comment poster 2025-12-08 16:15:34 -05:00
6a55846f19 improved error message readability 2025-12-08 15:21:14 -05:00
f6c4c3e508 Added documentation page
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m13s
2025-12-04 22:24:12 -05:00
60 changed files with 2618 additions and 657 deletions

View File

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

View File

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

View File

@@ -2,11 +2,32 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
import pool from '../db'; import pool from '../db';
import { approveApplication, createApplication, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService'; import { approveApplication, createApplication, denyApplication, getAllMemberApplications, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService';
import { MemberState, setUserState } from '../services/memberService'; import { setUserState } from '../services/memberService';
import { MemberState } from '@app/shared/types/member';
import { getRankByName, insertMemberRank } from '../services/rankService'; import { getRankByName, insertMemberRank } from '../services/rankService';
import { ApplicationFull, CommentRow } from "@app/shared/types/application" import { ApplicationFull, CommentRow } from "@app/shared/types/application"
import { assignUserToStatus } from '../services/statusService'; import { assignUserToStatus } from '../services/statusService';
import { Request, response, Response } from 'express';
import { getUserRoles } from '../services/rolesService';
//get CoC
router.get('/coc', async (req: Request, res: Response) => {
const output = await fetch(`${process.env.DOC_HOST}/api/pages/714`, {
headers: {
Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`,
}
})
if (output.ok) {
const out = await output.json();
res.status(200).json(out.html);
} else {
console.error("Failed to fetch LOA policy from bookstack");
res.sendStatus(500);
}
})
// POST /application // POST /application
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
@@ -16,13 +37,13 @@ router.post('/', async (req, res) => {
const appVersion = 1; const appVersion = 1;
createApplication(memberID, appVersion, JSON.stringify(App)) await createApplication(memberID, appVersion, JSON.stringify(App))
setUserState(memberID, MemberState.Applicant); await setUserState(memberID, MemberState.Applicant);
res.sendStatus(201); res.sendStatus(201);
} catch (err) { } catch (err) {
console.error('Insert failed:', err); console.error('Failed to create application: \n', err);
res.status(500).json({ error: 'Failed to save application' }); res.status(500).json({ error: 'Failed to create application' });
} }
}); });
@@ -37,6 +58,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) => { router.get('/me', async (req, res) => {
let userID = req.user.id; let userID = req.user.id;
@@ -62,13 +97,17 @@ router.get('/me', async (req, res) => {
}) })
// GET /application/:id // GET /application/:id
router.get('/:id', async (req, res) => { router.get('/me/:id', async (req: Request, res: Response) => {
let appID = req.params.id; let appID = Number(req.params.id);
console.log("HELLO") let member = req.user.id;
try { try {
const application = await getApplicationByID(appID); const application = await getApplicationByID(appID);
if (application === undefined) if (application === undefined)
return res.sendStatus(204); return res.sendStatus(204);
console.log(application.member_id, member)
if (application.member_id != member) {
return res.sendStatus(403);
}
const comments: CommentRow[] = await getApplicationComments(appID); const comments: CommentRow[] = await getApplicationComments(appID);
@@ -84,30 +123,64 @@ router.get('/:id', async (req, res) => {
} }
}); });
// GET /application/:id
router.get('/:id', async (req: Request, res: Response) => {
let appID = Number(req.params.id);
let asAdmin = !!req.query.admin || false;
let user = req.user.id;
//TODO: Replace this with bigger authorization system eventually
if (asAdmin) {
let allowed = (await getUserRoles(user)).some((role) =>
role.name.toLowerCase() === 'dev' ||
role.name.toLowerCase() === 'recruiter' ||
role.name.toLowerCase() === 'administrator')
console.log(allowed)
if (!allowed) {
return res.sendStatus(403)
}
}
try {
const application = await getApplicationByID(appID);
if (application === undefined)
return res.sendStatus(204);
const comments: CommentRow[] = await getApplicationComments(appID, asAdmin);
const output: ApplicationFull = {
application,
comments,
}
return res.status(200).json(output);
}
catch (err) {
console.error('Query failed:', err);
return res.status(500).json({ error: 'Failed to load application' });
}
});
// POST /application/approve/:id // POST /application/approve/:id
router.post('/approve/:id', async (req, res) => { router.post('/approve/:id', async (req: Request, res: Response) => {
const appID = req.params.id; const appID = Number(req.params.id);
const approved_by = req.user.id;
try { try {
const app = await getApplicationByID(appID); const app = await getApplicationByID(appID);
const result = await approveApplication(appID); const result = await approveApplication(appID);
console.log("START");
console.log(app, result);
//guard against failures //guard against failures
if (result.affectedRows != 1) { if (result.affectedRows != 1) {
throw new Error("Something went wrong approving the application"); throw new Error("Something went wrong approving the application");
} }
console.log(app.member_id);
//update user profile //update user profile
await setUserState(app.member_id, MemberState.Member); await setUserState(app.member_id, MemberState.Member);
let nextRank = await getRankByName('Recruit') await pool.query('CALL sp_accept_new_recruit_validation(?, ?, ?, ?)', [Number(process.env.CONFIG_ID), app.member_id, approved_by, approved_by])
await insertMemberRank(app.member_id, nextRank.id); // let nextRank = await getRankByName('Recruit')
//assign user to "pending basic" // await insertMemberRank(app.member_id, nextRank.id);
await assignUserToStatus(app.member_id, 1); // //assign user to "pending basic"
// await assignUserToStatus(app.member_id, 1);
res.sendStatus(200); res.sendStatus(200);
} catch (err) { } catch (err) {
console.error('Approve failed:', err); console.error('Approve failed:', err);
@@ -119,26 +192,11 @@ router.post('/approve/:id', async (req, res) => {
router.post('/deny/:id', async (req, res) => { router.post('/deny/:id', async (req, res) => {
const appID = req.params.id; const appID = req.params.id;
const sql = `
UPDATE applications
SET denied_at = NOW()
WHERE id = ?
AND approved_at IS NULL
AND denied_at IS NULL
`;
try { try {
const result = await pool.execute(sql, appID); const app = await getApplicationByID(appID);
await denyApplication(appID);
console.log(result); await setUserState(app.member_id, MemberState.Denied);
if (result.affectedRows === 0) {
res.status(400).json('Something went wrong denying the application');
}
if (result.affectedRows == 1) {
res.sendStatus(200); res.sendStatus(200);
}
} catch (err) { } catch (err) {
console.error('Approve failed:', err); console.error('Approve failed:', err);
res.status(500).json({ error: 'Failed to deny application' }); res.status(500).json({ error: 'Failed to deny application' });
@@ -146,10 +204,12 @@ router.post('/deny/:id', async (req, res) => {
}); });
// POST /application/:id/comment // POST /application/:id/comment
router.post('/:id/comment', async (req, res) => { router.post('/:id/comment', async (req: Request, res: Response) => {
const appID = req.params.id; const appID = req.params.id;
const data = req.body.message; const data = req.body.message;
const user = 1; const user = req.user;
console.log(user)
const sql = `INSERT INTO application_comments( const sql = `INSERT INTO application_comments(
application_id, application_id,
@@ -161,7 +221,7 @@ VALUES(?, ?, ?);`
try { try {
const conn = await pool.getConnection(); const conn = await pool.getConnection();
const result = await conn.query(sql, [appID, user, data]) const result = await conn.query(sql, [appID, user.id, data])
console.log(result) console.log(result)
if (result.affectedRows !== 1) { if (result.affectedRows !== 1) {
conn.release(); conn.release();
@@ -186,4 +246,61 @@ VALUES(?, ?, ?);`
} }
}); });
// POST /application/:id/comment
router.post('/:id/adminComment', async (req: Request, res: Response) => {
const appID = req.params.id;
const data = req.body.message;
const user = req.user;
console.log(user)
const sql = `INSERT INTO application_comments(
application_id,
poster_id,
post_content,
admin_only
)
VALUES(?, ?, ?, 1);`
try {
const conn = await pool.getConnection();
const result = await conn.query(sql, [appID, user.id, data])
console.log(result)
if (result.affectedRows !== 1) {
conn.release();
throw new Error("Insert Failure")
}
const getSQL = `SELECT app.id AS comment_id,
app.post_content,
app.poster_id,
app.post_time,
app.last_modified,
app.admin_only,
member.name AS poster_name
FROM application_comments AS app
INNER JOIN members AS member ON member.id = app.poster_id
WHERE app.id = ?; `;
const comment = await conn.query(getSQL, [result.insertId])
res.status(201).json(comment[0]);
} catch (err) {
console.error('Comment failed:', err);
res.status(500).json({ error: 'Could not post comment' });
}
});
router.post('/restart', async (req: Request, res: Response) => {
const user = req.user.id;
try {
await setUserState(user, MemberState.Guest);
res.sendStatus(200);
} catch (error) {
console.error('Comment failed:', error);
res.status(500).json({ error: 'Could not rester application' });
}
})
module.exports = router; module.exports = router;

View File

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

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

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

View File

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

View File

@@ -49,7 +49,7 @@ r.get('/', async (req, res) => {
const membersRoles = await con.query(` const membersRoles = await con.query(`
SELECT mr.role_id, v.* SELECT mr.role_id, v.*
FROM members_roles mr FROM members_roles mr
JOIN view_member_rank_status_all v ON mr.member_id = v.member_id JOIN view_member_rank_unit_status_latest v ON mr.member_id = v.member_id
`); `);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

@@ -0,0 +1,31 @@
export interface memberSettings {
displayName: string;
}
export enum MemberState {
Guest = "guest",
Applicant = "applicant",
Member = "member",
Retired = "retired",
Banned = "banned",
Denied = "denied"
}
export type Member = {
member_id: number;
member_name: string;
rank: string | null;
rank_date: string | null;
unit: string | null;
unit_date: string | null;
status: string | null;
status_date: string | null;
loa_until?: Date;
};
export interface MemberLight {
id: number
displayName: string
username: string
color: string
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,4 @@
export type LOARequest = { import { LOARequest, LOAType } from '@shared/types/loa'
id?: number;
name?: string;
member_id: number;
filed_date: string; // ISO 8601 string
start_date: string; // ISO 8601 string
end_date: string; // ISO 8601 string
reason?: string;
};
// @ts-ignore // @ts-ignore
const addr = import.meta.env.VITE_APIHOST; const addr = import.meta.env.VITE_APIHOST;
@@ -17,6 +9,24 @@ export async function submitLOA(request: LOARequest): Promise<{ id?: number; err
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(request), body: JSON.stringify(request),
credentials: 'include',
});
if (res.ok) {
return;
} else {
throw new Error("Failed to submit LOA");
}
}
export async function adminSubmitLOA(request: LOARequest): Promise<{ id?: number; error?: string }> {
const res = await fetch(`${addr}/loa/admin`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
credentials: 'include',
}); });
if (res.ok) { if (res.ok) {
@@ -26,6 +36,7 @@ export async function submitLOA(request: LOARequest): Promise<{ id?: number; err
} }
} }
export async function getMyLOA(): Promise<LOARequest | null> { export async function getMyLOA(): Promise<LOARequest | null> {
const res = await fetch(`${addr}/loa/me`, { const res = await fetch(`${addr}/loa/me`, {
method: "GET", method: "GET",
@@ -60,3 +71,84 @@ export function getAllLOAs(): Promise<LOARequest[]> {
} }
}); });
} }
export function getMyLOAs(): Promise<LOARequest[]> {
return fetch(`${addr}/loa/history`, {
method: "GET",
credentials: 'include',
headers: {
"Content-Type": "application/json",
},
}).then((res) => {
if (res.ok) {
return res.json();
} else {
return [];
}
});
}
export async function getLoaTypes(): Promise<LOAType[]> {
const res = await fetch(`${addr}/loa/types`, {
method: "GET",
credentials: 'include',
});
if (res.ok) {
const out = res.json();
if (!out) {
return null;
}
return out;
} else {
return null;
}
};
export async function getLoaPolicy(): Promise<string> {
const res = await fetch(`${addr}/loa/policy`, {
method: "GET",
credentials: 'include',
});
if (res.ok) {
const out = res.json();
if (!out) {
return null;
}
return out;
} else {
return null;
}
}
export async function cancelLOA(id: number, admin: boolean = false) {
let route = admin ? 'adminCancel' : 'cancel';
const res = await fetch(`${addr}/loa/${route}/${id}`, {
method: "POST",
credentials: 'include',
});
if (res.ok) {
return
} else {
throw new Error("Could not cancel LOA");
}
}
export async function extendLOA(id: number, to: Date) {
const res = await fetch(`${addr}/loa/extend/${id}`, {
method: "POST",
credentials: 'include',
body: JSON.stringify({ to }),
headers: {
"Content-Type": "application/json",
}
});
if (res.ok) {
return
} else {
throw new Error("Could not extend LOA");
}
}

View File

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

View File

@@ -166,3 +166,111 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
/* Root container */
.bookstack-container {
font-family: var(--font-sans, system-ui), sans-serif;
color: var(--foreground);
line-height: 1.45;
max-width: 760px;
margin: 0 auto;
font-size: 0.9rem;
}
/* Headers */
.bookstack-container h4 {
margin: 0.9rem 0 0.4rem 0;
font-weight: 600;
line-height: 1.35;
font-size: 1.05rem;
color: var(--foreground);
}
.bookstack-container h5 {
margin: 0.9rem 0 0.4rem 0;
font-weight: 600;
line-height: 1.35;
font-size: 0.95rem;
color: var(--foreground);
}
/* Lists */
.bookstack-container ul {
list-style-type: disc;
margin-left: 1.1rem;
margin-bottom: 0.6rem;
padding-left: 0.6rem;
color: var(--muted-foreground);
}
/* Nested lists */
.bookstack-container ul ul {
list-style-type: circle;
margin-left: 0.9rem;
}
/* List items */
.bookstack-container li {
margin: 0.15rem 0;
padding-left: 0.1rem;
color: var(--muted-foreground);
}
/* Bullet color */
.bookstack-container li::marker {
color: var(--muted-foreground);
}
/* Inline elements */
.bookstack-container li p,
.bookstack-container li span,
.bookstack-container p {
display: inline;
margin: 0;
padding: 0;
color: var(--muted-foreground);
}
/* Top-level spacing */
.bookstack-container>ul>li {
margin-top: 0.3rem;
}
/* links */
.bookstack-container a {
color: var(--color-primary);
margin-top: 0.3rem;
}
.bookstack-container a:hover {
text-decoration: underline;
}
/* Scrollbar stuff */
/* Firefox */
.scrollbar-themed {
scrollbar-width: thin;
scrollbar-color: #555 #1f1f1f;
padding-right: 6px;
}
/* Chrome, Edge, Safari */
.scrollbar-themed::-webkit-scrollbar {
width: 10px;
/* slightly wider to allow padding look */
}
.scrollbar-themed::-webkit-scrollbar-track {
background: #1f1f1f;
margin-left: 6px;
/* ❗ adds space between content + scrollbar */
}
.scrollbar-themed::-webkit-scrollbar-thumb {
background: #555;
border-radius: 9999px;
}
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
background: #777;
}

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import DropdownMenuTrigger from '../ui/dropdown-menu/DropdownMenuTrigger.vue';
import DropdownMenuContent from '../ui/dropdown-menu/DropdownMenuContent.vue'; import DropdownMenuContent from '../ui/dropdown-menu/DropdownMenuContent.vue';
import DropdownMenuItem from '../ui/dropdown-menu/DropdownMenuItem.vue'; import DropdownMenuItem from '../ui/dropdown-menu/DropdownMenuItem.vue';
import { Calendar } from 'lucide-vue-next'; import { Calendar } from 'lucide-vue-next';
import MemberCard from '../members/MemberCard.vue';
const route = useRoute(); const route = useRoute();
@@ -239,7 +240,7 @@ defineExpose({ forceReload })
<MapPin :size="20"></MapPin> {{ activeEvent.location || "Unknown" }} <MapPin :size="20"></MapPin> {{ activeEvent.location || "Unknown" }}
</div> </div>
<div class="text-foreground/80 flex gap-3 items-center"> <div class="text-foreground/80 flex gap-3 items-center">
<User :size="20"></User> {{ activeEvent.creator_name || "Unknown User" }} <User :size="20"></User> <MemberCard :member-id="activeEvent.creator_id"></MemberCard>
</div> </div>
</section> </section>
<!-- Description --> <!-- Description -->
@@ -276,7 +277,9 @@ defineExpose({ forceReload })
<div v-for="person in attendanceList" :key="person.member_id" <div v-for="person in attendanceList" :key="person.member_id"
class="grid grid-cols-2 py-1 *:px-3 hover:bg-muted"> class="grid grid-cols-2 py-1 *:px-3 hover:bg-muted">
<p>{{ person.member_name }}</p> <div>
<MemberCard :member-id="person.member_id"></MemberCard>
</div>
<p :class="statusColor(person.status)" class="text-right"> <p :class="statusColor(person.status)" class="text-right">
{{ displayStatus(person.status) }} {{ displayStatus(person.status) }}
</p> </p>

View File

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

View File

@@ -16,30 +16,54 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Ellipsis } from "lucide-vue-next"; import { Ellipsis } from "lucide-vue-next";
import { getAllLOAs, LOARequest } from "@/api/loa"; import { cancelLOA, extendLOA, getAllLOAs, getMyLOAs } from "@/api/loa";
import { onMounted, ref, computed } from "vue"; import { onMounted, ref, computed } from "vue";
import { LOARequest } from "@shared/types/loa";
import Dialog from "../ui/dialog/Dialog.vue";
import DialogTrigger from "../ui/dialog/DialogTrigger.vue";
import DialogContent from "../ui/dialog/DialogContent.vue";
import DialogHeader from "../ui/dialog/DialogHeader.vue";
import DialogTitle from "../ui/dialog/DialogTitle.vue";
import DialogDescription from "../ui/dialog/DialogDescription.vue";
import Button from "../ui/button/Button.vue";
import Calendar from "../ui/calendar/Calendar.vue";
import {
CalendarDate,
getLocalTimeZone,
} from "@internationalized/date"
import { el } from "@fullcalendar/core/internal-common";
import MemberCard from "../members/MemberCard.vue";
const props = defineProps<{
adminMode?: boolean
}>()
const LOAList = ref<LOARequest[]>([]); const LOAList = ref<LOARequest[]>([]);
onMounted(async () => { onMounted(async () => {
LOAList.value = await getAllLOAs(); await loadLOAs();
}); });
function formatDate(dateStr: string): string { async function loadLOAs() {
if (!dateStr) return ""; if (props.adminMode) {
return new Date(dateStr).toLocaleDateString("en-US", { LOAList.value = await getAllLOAs();
} else {
LOAList.value = await getMyLOAs();
}
}
function formatDate(date: Date): string {
if (!date) return "";
date = typeof date === 'string' ? new Date(date) : date;
return date.toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
}); });
} }
function loaStatus(loa: { function loaStatus(loa: LOARequest): "Upcoming" | "Active" | "Overdue" | "Closed" {
start_date: string; if (loa.closed) return "Closed";
end_date: string;
deleted?: number;
}): "Upcoming" | "Active" | "Expired" | "Cancelled" {
if (loa.deleted) return "Cancelled";
const now = new Date(); const now = new Date();
const start = new Date(loa.start_date); const start = new Date(loa.start_date);
@@ -47,9 +71,9 @@ function loaStatus(loa: {
if (now < start) return "Upcoming"; if (now < start) return "Upcoming";
if (now >= start && now <= end) return "Active"; if (now >= start && now <= end) return "Active";
if (now > end) return "Expired"; if (now > end) return "Overdue";
return "Expired"; // fallback return "Overdue"; // fallback
} }
function sortByStartDate(loas: LOARequest[]): LOARequest[] { function sortByStartDate(loas: LOARequest[]): LOARequest[] {
@@ -58,50 +82,108 @@ function sortByStartDate(loas: LOARequest[]): LOARequest[] {
); );
} }
const sortedLoas = computed(() => sortByStartDate(LOAList.value)); async function cancelAndReload(id: number) {
await cancelLOA(id, props.adminMode);
await loadLOAs();
}
const isExtending = ref(false);
const targetLOA = ref<LOARequest | null>(null);
const extendTo = ref<CalendarDate | null>(null);
const targetEnd = computed(() => { return targetLOA.value.extended_till ? targetLOA.value.extended_till : targetLOA.value.end_date })
function toCalendarDate(date: Date): CalendarDate {
if (typeof date === 'string')
date = new Date(date);
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
}
async function commitExtend() {
await extendLOA(targetLOA.value.id, extendTo.value.toDate(getLocalTimeZone()));
isExtending.value = false;
await loadLOAs();
}
</script> </script>
<template> <template>
<div class="w-5xl mx-auto"> <div>
<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">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="w-[100px]">Member</TableHead> <TableHead>Member</TableHead>
<TableHead>Type</TableHead>
<TableHead>Start</TableHead> <TableHead>Start</TableHead>
<TableHead>End</TableHead> <TableHead>End</TableHead>
<TableHead>Reason</TableHead> <TableHead class="w-[500px]">Reason</TableHead>
<TableHead>Posted on</TableHead> <TableHead>Posted on</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow v-for="post in sortedLoas" :key="post.id" class="hover:bg-muted/50"> <TableRow v-for="post in LOAList" :key="post.id" class="hover:bg-muted/50">
<TableCell class="font-medium"> <TableCell class="font-medium">
{{ post.name }} <MemberCard :member-id="post.member_id"></MemberCard>
</TableCell> </TableCell>
<TableCell>{{ post.type_name }}</TableCell>
<TableCell>{{ formatDate(post.start_date) }}</TableCell> <TableCell>{{ formatDate(post.start_date) }}</TableCell>
<TableCell>{{ formatDate(post.end_date) }}</TableCell> <TableCell>{{ post.extended_till ? formatDate(post.extended_till) : formatDate(post.end_date) }}
</TableCell>
<TableCell>{{ post.reason }}</TableCell> <TableCell>{{ post.reason }}</TableCell>
<TableCell>{{ formatDate(post.filed_date) }}</TableCell> <TableCell>{{ formatDate(post.filed_date) }}</TableCell>
<TableCell> <TableCell>
<Badge v-if="loaStatus(post) === 'Upcoming'" class="bg-blue-500">Upcoming</Badge> <Badge v-if="loaStatus(post) === 'Upcoming'" class="bg-blue-400">Upcoming</Badge>
<Badge v-else-if="loaStatus(post) === 'Active'" class="bg-green-500">Active</Badge> <Badge v-else-if="loaStatus(post) === 'Active'" class="bg-green-500">Active</Badge>
<Badge v-else-if="loaStatus(post) === 'Expired'" class="bg-gray-400">Expired</Badge> <Badge v-else-if="loaStatus(post) === 'Overdue'" class="bg-yellow-400">Overdue</Badge>
<Badge v-else class="bg-red-500">Cancelled</Badge> <Badge v-else class="bg-gray-400">Ended</Badge>
</TableCell> </TableCell>
<TableCell @click.stop="console.log('hi')" class="text-right"> <TableCell @click.stop="" class="text-right">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer"> <DropdownMenuTrigger class="cursor-pointer">
<Ellipsis></Ellipsis> <Ellipsis></Ellipsis>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem :variant="'destructive'">Cancel</DropdownMenuItem> <DropdownMenuItem v-if="!post.closed && props.adminMode"
@click="isExtending = true; targetLOA = post">
Extend
</DropdownMenuItem>
<DropdownMenuItem v-if="!post.closed" :variant="'destructive'"
@click="cancelAndReload(post.id)">End
</DropdownMenuItem>
<!-- Fallback: no actions available -->
<p v-if="post.closed || (!props.adminMode && post.closed)" class="p-2 text-center text-sm">
No actions
</p>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import { getAllApplications, approveApplication, denyApplication, ApplicationStatus } from '@/api/application'; import { getAllApplications, approveApplication, denyApplication } from '@/api/application';
import { ApplicationStatus } from '@shared/types/application'
import { import {
Table, Table,
TableBody, TableBody,
@@ -14,6 +15,7 @@ import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { CheckIcon, XIcon } from 'lucide-vue-next'; import { CheckIcon, XIcon } from 'lucide-vue-next';
import Application from './Application.vue'; import Application from './Application.vue';
import MemberCard from '@/components/members/MemberCard.vue';
const appList = ref([]); const appList = ref([]);
const now = Date.now(); const now = Date.now();
@@ -95,7 +97,8 @@ onMounted(async () => {
<!-- application list --> <!-- application list -->
<div :class="openPanel == false ? 'w-full' : 'w-2/5'" class="pr-9"> <div :class="openPanel == false ? 'w-full' : 'w-2/5'" class="pr-9">
<h1 class="scroll-m-20 text-2xl font-semibold tracking-tight">Manage Applications</h1> <h1 class="scroll-m-20 text-2xl font-semibold tracking-tight">Manage Applications</h1>
<Table> <div class="max-h-[80vh] overflow-hidden">
<Table class="w-full">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>User</TableHead> <TableHead>User</TableHead>
@@ -103,32 +106,44 @@ onMounted(async () => {
<TableHead class="text-right">Status</TableHead> <TableHead class="text-right">Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody class="overflow-y-auto scrollbar-themed"> </Table>
<!-- Scrollable body container -->
<div class="overflow-y-auto max-h-[70vh] scrollbar-themed">
<Table class="w-full">
<TableBody>
<TableRow v-for="app in appList" :key="app.id" class="cursor-pointer" <TableRow v-for="app in appList" :key="app.id" class="cursor-pointer"
:onClick="() => { openApplication(app.id) }"> @click="openApplication(app.id)">
<TableCell class="font-medium">{{ app.member_name }}</TableCell> <TableCell class="font-medium">
<MemberCard :memberId="app.member_id"></MemberCard>
</TableCell>
<TableCell :title="formatExact(app.submitted_at)"> <TableCell :title="formatExact(app.submitted_at)">
{{ formatAgo(app.submitted_at) }} {{ formatAgo(app.submitted_at) }}
</TableCell> </TableCell>
<TableCell v-if="app.app_status === ApplicationStatus.Pending" <TableCell v-if="app.app_status === ApplicationStatus.Pending"
class="inline-flex items-end gap-2"> class="inline-flex items-end gap-2">
<Button variant="success" @click.stop="() => { handleApprove(app.id) }"> <Button variant="success" @click.stop="handleApprove(app.id)">
<CheckIcon></CheckIcon> <CheckIcon />
</Button> </Button>
<Button variant="destructive" @click.stop="() => { handleDeny(app.id) }"> <Button variant="destructive" @click.stop="handleDeny(app.id)">
<XIcon></XIcon> <XIcon />
</Button> </Button>
</TableCell> </TableCell>
<TableCell class="text-right font-semibold" :class="[ <TableCell class="text-right font-semibold" :class="[
,
app.app_status === ApplicationStatus.Pending && 'text-yellow-500', app.app_status === ApplicationStatus.Pending && 'text-yellow-500',
app.app_status === ApplicationStatus.Accepted && 'text-green-500', app.app_status === ApplicationStatus.Accepted && 'text-green-500',
app.app_status === ApplicationStatus.Denied && 'text-destructive' app.app_status === ApplicationStatus.Denied && 'text-destructive'
]">{{ app.app_status }}</TableCell> ]">
{{ app.app_status }}
</TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
</div>
<div v-if="openPanel" class="pl-9 border-l w-3/5" :key="$route.params.id"> <div v-if="openPanel" class="pl-9 border-l w-3/5" :key="$route.params.id">
<div class="mb-5 flex justify-between"> <div class="mb-5 flex justify-between">
<p class="scroll-m-20 text-2xl font-semibold tracking-tight"> Application</p> <p class="scroll-m-20 text-2xl font-semibold tracking-tight"> Application</p>
@@ -143,32 +158,4 @@ onMounted(async () => {
</div> </div>
</template> </template>
<style scoped> <style scoped></style>
/* Firefox */
.scrollbar-themed {
scrollbar-width: thin;
scrollbar-color: #555 #1f1f1f;
padding-right: 6px;
}
/* Chrome, Edge, Safari */
.scrollbar-themed::-webkit-scrollbar {
width: 10px;
/* slightly wider to allow padding look */
}
.scrollbar-themed::-webkit-scrollbar-track {
background: #1f1f1f;
margin-left: 6px;
/* ❗ adds space between content + scrollbar */
}
.scrollbar-themed::-webkit-scrollbar-thumb {
background: #555;
border-radius: 9999px;
}
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ import SelectValue from '@/components/ui/select/SelectValue.vue';
import SelectContent from '@/components/ui/select/SelectContent.vue'; import SelectContent from '@/components/ui/select/SelectContent.vue';
import SelectItem from '@/components/ui/select/SelectItem.vue'; import SelectItem from '@/components/ui/select/SelectItem.vue';
import Input from '@/components/ui/input/Input.vue'; import Input from '@/components/ui/input/Input.vue';
import MemberCard from '@/components/members/MemberCard.vue';
enum sidePanelState { view, create, closed }; enum sidePanelState { view, create, closed };
@@ -152,9 +153,13 @@ onMounted(async () => {
<TableCell class="font-medium">{{ report.course_name.length > 30 ? report.course_shortname : <TableCell class="font-medium">{{ report.course_name.length > 30 ? report.course_shortname :
report.course_name }}</TableCell> report.course_name }}</TableCell>
<TableCell>{{ report.date.split('T')[0] }}</TableCell> <TableCell>{{ report.date.split('T')[0] }}</TableCell>
<TableCell class="text-right">{{ report.created_by_name === null ? "Unknown User" : <TableCell class="text-right">
<MemberCard v-if="report.created_by_name" :member-id="report.created_by"></MemberCard>
<span v-else>Unknown User</span>
</TableCell>
<!-- <TableCell class="text-right">{{ report.created_by_name === null ? "Unknown User" :
report.created_by_name report.created_by_name
}}</TableCell> }}</TableCell> -->
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
@@ -172,11 +177,14 @@ onMounted(async () => {
<div class="flex flex-col mb-5 border rounded-lg bg-muted/70 p-2 py-3 px-4"> <div class="flex flex-col mb-5 border rounded-lg bg-muted/70 p-2 py-3 px-4">
<p class="scroll-m-20 text-xl font-semibold tracking-tight">{{ focusedTrainingReport.course_name }} <p class="scroll-m-20 text-xl font-semibold tracking-tight">{{ focusedTrainingReport.course_name }}
</p> </p>
<div class="flex gap-10"> <div class="flex gap-10 items-center">
<p class="text-muted-foreground">{{ focusedTrainingReport.event_date.split('T')[0] }}</p> <p class="text-muted-foreground">{{ focusedTrainingReport.event_date.split('T')[0] }}</p>
<p class="">Created by {{ focusedTrainingReport.created_by_name === null ? "Unknown User" : <p class="flex gap-2 items-center">Created by:
<MemberCard v-if="focusedTrainingReport.created_by"
:member-id="focusedTrainingReport.created_by" />
<p v-else>{{ focusedTrainingReport.created_by_name === null ? "Unknown User" :
focusedTrainingReport.created_by_name focusedTrainingReport.created_by_name
}} }}</p>
</p> </p>
</div> </div>
</div> </div>
@@ -191,7 +199,11 @@ onMounted(async () => {
</div> </div>
<div v-for="person in focusedTrainingTrainers" <div v-for="person in focusedTrainingTrainers"
class="grid grid-cols-4 py-2 items-center border-b last:border-none"> class="grid grid-cols-4 py-2 items-center border-b last:border-none">
<p>{{ person.attendee_name }}</p> <div>
<MemberCard v-if="person.attendee_id" :member-id="person.attendee_id"
class="justify-self-start"></MemberCard>
<p v-else>{{ person.attendee_name }}</p>
</div>
<p class="">{{ person.role.name }}</p> <p class="">{{ person.role.name }}</p>
<p class="col-span-2 text-right px-2" <p class="col-span-2 text-right px-2"
:class="person.remarks == '' ? 'text-muted-foreground' : ''"> :class="person.remarks == '' ? 'text-muted-foreground' : ''">
@@ -213,7 +225,11 @@ onMounted(async () => {
</div> </div>
<div v-for="person in focusedTrainingTrainees" <div v-for="person in focusedTrainingTrainees"
class="grid grid-cols-5 py-2 items-center border-b last:border-none"> class="grid grid-cols-5 py-2 items-center border-b last:border-none">
<p>{{ person.attendee_name }}</p> <div>
<MemberCard v-if="person.attendee_id" :member-id="person.attendee_id"
class="justify-self-start"></MemberCard>
<p v-else>{{ person.attendee_name }}</p>
</div>
<Checkbox :disabled="!focusedTrainingReport.course.hasQual" <Checkbox :disabled="!focusedTrainingReport.course.hasQual"
:model-value="person.passed_bookwork" class="pointer-events-none ml-5"> :model-value="person.passed_bookwork" class="pointer-events-none ml-5">
</Checkbox> </Checkbox>
@@ -242,7 +258,11 @@ onMounted(async () => {
</div> </div>
<div v-for="person in focusedNoShows" <div v-for="person in focusedNoShows"
class="grid grid-cols-5 py-2 items-center border-b last:border-none"> class="grid grid-cols-5 py-2 items-center border-b last:border-none">
<p>{{ person.attendee_name }}</p> <div>
<MemberCard v-if="person.attendee_id" :member-id="person.attendee_id"
class="justify-self-start"></MemberCard>
<p v-else>{{ person.attendee_name }}</p>
</div>
<!-- <Checkbox :default-value="person.passed_bookwork ? true : false" class="pointer-events-none"> <!-- <Checkbox :default-value="person.passed_bookwork ? true : false" class="pointer-events-none">
</Checkbox> </Checkbox>
<Checkbox :default-value="person.passed_qual ? true : false" class="pointer-events-none"> <Checkbox :default-value="person.passed_qual ? true : false" class="pointer-events-none">

View File

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

View File

@@ -6,18 +6,24 @@ const router = createRouter({
routes: [ routes: [
// PUBLIC // PUBLIC
{ path: '/join', component: () => import('@/pages/Join.vue') }, { path: '/join', component: () => import('@/pages/Join.vue') },
{ path: '/applications', component: () => import('@/pages/MyApplications.vue'), meta: { requiresAuth: true } },
{ path: '/applications/:id', component: () => import('@/pages/MyApplications.vue'), meta: { requiresAuth: true } },
// AUTH REQUIRED // AUTH REQUIRED
{ path: '/apply', component: () => import('@/pages/Application.vue'), meta: { requiresAuth: true } },
{ path: '/', component: () => import('@/pages/Homepage.vue') }, { path: '/', component: () => import('@/pages/Homepage.vue') },
// MEMBER ROUTES // MEMBER ROUTES
{ path: '/members', component: () => import('@/pages/memberList.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/members', component: () => import('@/pages/memberList.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/transfer', component: () => import('@/pages/Transfer.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/transfer', component: () => import('@/pages/Transfer.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/profile', component: () => import('@/pages/MyProfile.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/calendar', component: () => import('@/pages/Calendar.vue'), meta: { }, },
{ path: '/calendar/event/:id', component: () => import('@/pages/Calendar.vue'), meta: { }, }, { path: '/calendar', component: () => import('@/pages/Calendar.vue'), 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', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/trainingReport/new', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/trainingReport/new', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },

View File

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