Merge branch 'main' into promotions

This commit is contained in:
2025-12-30 20:57:18 -06:00
27 changed files with 759 additions and 415 deletions

39
api/package-lock.json generated
View File

@@ -10,7 +10,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@sentry/node": "^10.27.0", "@sentry/node": "^10.27.0",
"chalk": "^5.6.2", "@types/express-session": "^1.18.2",
"connect-sqlite3": "^0.9.16", "connect-sqlite3": "^0.9.16",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
@@ -758,7 +758,6 @@
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/connect": "*", "@types/connect": "*",
@@ -778,7 +777,6 @@
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz",
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
@@ -790,7 +788,6 @@
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
"integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
@@ -799,6 +796,15 @@
"@types/send": "*" "@types/send": "*"
} }
}, },
"node_modules/@types/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==",
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/geojson": { "node_modules/@types/geojson": {
"version": "7946.0.16", "version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -809,14 +815,12 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/morgan": { "node_modules/@types/morgan": {
@@ -871,21 +875,18 @@
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/range-parser": { "node_modules/@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/send": { "node_modules/@types/send": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz",
"integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@@ -895,7 +896,6 @@
"version": "1.15.9", "version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz",
"integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/http-errors": "*", "@types/http-errors": "*",
@@ -907,7 +907,6 @@
"version": "0.17.5", "version": "0.17.5",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/mime": "^1", "@types/mime": "^1",
@@ -1315,18 +1314,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -3235,9 +3222,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.0", "version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"

View File

@@ -13,7 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@sentry/node": "^10.27.0", "@sentry/node": "^10.27.0",
"chalk": "^5.6.2", "@types/express-session": "^1.18.2",
"connect-sqlite3": "^0.9.16", "connect-sqlite3": "^0.9.16",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",

View File

@@ -5,24 +5,24 @@ import express = require('express');
import cors = require('cors'); import cors = require('cors');
import morgan = require('morgan'); import morgan = require('morgan');
const app = express() const app = express()
import chalk from 'chalk';
app.use(morgan((tokens: morgan.TokenIndexer, req: express.Request, res: express.Response) => { app.use(morgan((tokens: morgan.TokenIndexer, req: express.Request, res: express.Response) => {
const status = Number(tokens.status(req, res)); return JSON.stringify({
type: 'http',
timestamp: new Date().toISOString(),
// Colorize status code method: tokens.method(req, res),
const statusColor = status >= 500 ? chalk.red path: tokens.url(req, res),
: status >= 400 ? chalk.yellow status: Number(tokens.status(req, res)),
: status >= 300 ? chalk.cyan response_time_ms: Number(tokens['response-time'](req, res)),
: chalk.green;
return [ ip: req.ip,
chalk.gray(`[${new Date().toISOString()}]`), user_agent: req.headers['user-agent'],
chalk.blue.bold(tokens.method(req, res)),
tokens.url(req, res), user: req.user
statusColor(status), ? { id: req.user.id, name: req.user.name }
chalk.magenta(tokens['response-time'](req, res) + ' ms'), : null,
chalk.yellow(`- User: ${req.user?.name ? `${req.user.name} (${req.user.id})` : 'Unauthenticated'}`), });
].join(' ');
}, { }, {
skip: (req: express.Request) => { skip: (req: express.Request) => {
return req.originalUrl === '/members/me'; return req.originalUrl === '/members/me';
@@ -55,21 +55,27 @@ if (process.env.DISABLE_GLITCHTIP === "true") {
//session setup //session setup
import path = require('path'); import path = require('path');
// import session = require('express-session');
import session = require('express-session'); import session = require('express-session');
import passport = require('passport'); import passport = require('passport');
const SQLiteStore = require('connect-sqlite3')(session); const SQLiteStore = require('connect-sqlite3')(session);
app.use(session({ const cookieOptions: session.CookieOptions = {
httpOnly: true,
sameSite: 'lax',
domain: process.env.CLIENT_DOMAIN,
maxAge: 1000 * 60 * 60 * 24 * 30, //30 days
}
const sessionOptions: session.SessionOptions = {
secret: 'whatever', secret: 'whatever',
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
store: new SQLiteStore({ db: 'sessions.db', dir: './' }), store: new SQLiteStore({ db: 'sessions.db', dir: './' }),
cookie: { rolling: true,
httpOnly: true, cookie: cookieOptions
sameSite: 'lax', }
domain: process.env.CLIENT_DOMAIN
} app.use(session(sessionOptions));
}));
app.use(passport.authenticate('session')); app.use(passport.authenticate('session'));
// Mount route modules // Mount route modules

View File

@@ -80,8 +80,10 @@ router.get('/me', [requireLogin], async (req, res) => {
try { try {
let application = await getMemberApplication(userID); let application = await getMemberApplication(userID);
if (application === undefined) if (application === undefined) {
res.sendStatus(204); res.sendStatus(204);
return;
}
const comments: CommentRow[] = await getApplicationComments(application.id); const comments: CommentRow[] = await getApplicationComments(application.id);

View File

@@ -46,32 +46,35 @@ passport.use(new OpenIDConnectStrategy({
//lookup existing user //lookup existing user
const existing = await con.query(`SELECT id FROM members WHERE authentik_issuer = ? AND authentik_sub = ? LIMIT 1;`, [issuer, sub]); const existing = await con.query(`SELECT id FROM members WHERE authentik_issuer = ? AND authentik_sub = ? LIMIT 1;`, [issuer, sub]);
let memberId: number; let memberId: number | null = null;
//if member exists //if member exists
if (existing.length > 0) { if (existing.length > 0) {
memberId = existing[0].id; memberId = existing[0].id;
} else { } else {
//otherwise: create account //otherwise: create account mode
const jwt = parseJwt(jwtClaims); const jwt = parseJwt(jwtClaims);
const discordID = jwt.discord.id as number; const discordID = jwt.discord?.id as number;
//check if account is available to claim //check if account is available to claim
memberId = await mapDiscordtoID(discordID); if (discordID)
memberId = await mapDiscordtoID(discordID);
if (memberId === null) { if (discordID && memberId) {
// create new account // claim account
console.log("Claiming account");
const result = await con.query(
`UPDATE members SET authentik_sub = ?, authentik_issuer = ? WHERE id = ?;`,
[sub, issuer, memberId]
)
} else {
console.log("New Account");
// new account
const username = sub.username; const username = sub.username;
const result = await con.query( const result = await con.query(
`INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`, `INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`,
[username, sub, issuer] [username, sub, issuer]
) )
memberId = Number(result.insertId); memberId = Number(result.insertId);
} else {
// claim existing account
const result = await con.query(
`UPDATE members SET authentik_sub = ?, authentik_issuer = ? WHERE id = ?;`,
[sub, issuer, memberId]
)
} }
} }

View File

@@ -26,7 +26,7 @@ router.post("/", async (req: Request, res: Response) => {
}); });
//admin posts LOA //admin posts LOA
router.post("/admin", [requireRole("17th Administrator")], async (req: Request, res: Response) => { router.post("/admin", [requireRole(['17th Administrator', '17th HQ', '17th Command'])], async (req: Request, res: Response) => {
let LOARequest = req.body as LOARequest; let LOARequest = req.body as LOARequest;
LOARequest.created_by = req.user.id; LOARequest.created_by = req.user.id;
LOARequest.filed_date = new Date(); LOARequest.filed_date = new Date();
@@ -67,7 +67,7 @@ router.get("/history", async (req: Request, res: Response) => {
} }
}) })
router.get('/all', [requireRole("17th Administrator")], async (req: Request, res: Response) => { router.get('/all', [requireRole(['17th Administrator', '17th HQ', '17th Command'])], async (req: Request, res: Response) => {
try { try {
const page = Number(req.query.page) || undefined; const page = Number(req.query.page) || undefined;
const pageSize = Number(req.query.pageSize) || undefined; const pageSize = Number(req.query.pageSize) || undefined;
@@ -107,7 +107,7 @@ router.post('/cancel/:id', async (req: Request, res: Response) => {
}) })
//TODO: enforce admin only //TODO: enforce admin only
router.post('/adminCancel/:id', [requireRole("17th Administrator")], async (req: Request, res: Response) => { router.post('/adminCancel/:id', [requireRole(['17th Administrator', '17th HQ', '17th Command'])], async (req: Request, res: Response) => {
let closer = req.user.id; let closer = req.user.id;
try { try {
await closeLOA(Number(req.params.id), closer); await closeLOA(Number(req.params.id), closer);
@@ -119,7 +119,7 @@ router.post('/adminCancel/:id', [requireRole("17th Administrator")], async (req:
}) })
// TODO: Enforce admin only // TODO: Enforce admin only
router.post('/extend/:id', [requireRole("17th Administrator")], async (req: Request, res: Response) => { router.post('/extend/:id', [requireRole(['17th Administrator', '17th HQ', '17th Command'])], async (req: Request, res: Response) => {
const to: Date = req.body.to; const to: Date = req.body.to;
if (!to) { if (!to) {

View File

@@ -5,7 +5,8 @@ const ur = express.Router();
import { MemberState } from '@app/shared/types/member'; import { MemberState } from '@app/shared/types/member';
import pool from '../db'; import pool from '../db';
import { requireLogin, requireMemberState, requireRole } from '../middleware/auth'; import { requireLogin, requireMemberState, requireRole } from '../middleware/auth';
import { assignUserGroup, createGroup } from '../services/rolesService'; import { assignUserGroup, createGroup, getAllRoles, getRole, getUsersWithRole } from '../services/rolesService';
import { Request, Response } from 'express';
r.use(requireLogin) r.use(requireLogin)
ur.use(requireLogin) ur.use(requireLogin)
@@ -15,10 +16,16 @@ ur.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administ
try { try {
const body = req.body; const body = req.body;
assignUserGroup(body.member_id, body.role_id); await assignUserGroup(body.member_id, body.role_id);
res.sendStatus(201); res.sendStatus(201);
} catch (err) { } catch (err) {
if (err?.code === 'ER_DUP_ENTRY') {
return res.status(400).json({
error: 'Member already has this role',
});
}
console.error('Insert failed:', err); console.error('Insert failed:', err);
res.status(500).json({ error: 'Failed to add to group' }); res.status(500).json({ error: 'Failed to add to group' });
} }
@@ -44,45 +51,39 @@ ur.delete('/', [requireMemberState(MemberState.Member), requireRole("17th Admini
//get all roles //get all roles
r.get('/', [requireMemberState(MemberState.Member)], async (req, res) => { r.get('/', [requireMemberState(MemberState.Member)], async (req, res) => {
try { try {
var con = await pool.getConnection(); const roles = await getAllRoles();
res.status(200).json(roles);
// Get all roles
const roles = await con.query('SELECT * FROM roles;');
// Get all members for each role
const membersRoles = await con.query(`
SELECT mr.role_id, v.*
FROM members_roles mr
JOIN view_member_rank_unit_status_latest v ON mr.member_id = v.member_id
`);
// Group members by role_id
const roleIdToMembers = {};
for (const row of membersRoles) {
if (!roleIdToMembers[row.role_id]) roleIdToMembers[row.role_id] = [];
// Remove role_id from member object
const { role_id, ...member } = row;
roleIdToMembers[role_id].push(member);
}
// Attach members to each role
const result = roles.map(role => ({
...role,
members: roleIdToMembers[role.id] || []
}));
res.json(result);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Internal server error' }); res.sendStatus(500);
} finally {
con.release();
} }
}); });
r.get('/:id/members', [requireMemberState(MemberState.Member)], async (req: Request, res: Response) => {
try {
const members = await getUsersWithRole(Number(req.params.id));
res.status(200).json(members);
} catch (err) {
console.error(err);
res.sendStatus(500);
}
})
r.get('/:id', [requireMemberState(MemberState.Member)], async (req: Request, res: Response) => {
try {
const role = await getRole(Number(req.params.id));
res.status(200).json(role);
} catch (err) {
console.error(err);
res.sendStatus(500);
}
})
//create a new role //create a new role
r.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => { r.post('/', [requireMemberState(MemberState.Member), requireRole("dev")], async (req, res) => {
try { try {
const { name, color, description } = req.body; const { name, color, description } = req.body;
if (!name || !color) { if (!name || !color) {
@@ -103,7 +104,7 @@ r.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administr
} }
}) })
r.delete('/:id', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => { r.delete('/:id', [requireMemberState(MemberState.Member), requireRole("dev")], async (req, res) => {
try { try {
const id = req.params.id; const id = req.params.id;

View File

@@ -83,8 +83,10 @@ export async function insertCourseEvent(event: CourseEventDetails): Promise<numb
try { try {
var con = await pool.getConnection(); var con = await pool.getConnection();
let course: Course = await getCourseByID(event.course_id);
await con.beginTransaction(); await con.beginTransaction();
const res = await con.query("INSERT INTO course_events (course_id, event_date, remarks, created_by) VALUES (?, ?, ?, ?);", [event.course_id, toDateTime(event.event_date), event.remarks, event.created_by]); const res = await con.query("INSERT INTO course_events (course_id, event_date, remarks, created_by, hasBookwork, hasQual) VALUES (?, ?, ?, ?, ?, ?);", [event.course_id, toDateTime(event.event_date), event.remarks, event.created_by, course.hasBookwork, course.hasQual]);
var eventID: number = res.insertId; var eventID: number = res.insertId;
for (const attendee of event.attendees) { for (const attendee of event.attendees) {

View File

@@ -1,5 +1,6 @@
import { Role } from "@app/shared/types/roles";
import pool from "../db"; import pool from "../db";
import { Member, MemberLight, memberSettings, MemberState } from '@app/shared/types/member' import { Member, MemberCardDetails, MemberLight, memberSettings, MemberState } from '@app/shared/types/member'
export async function getUserData(userID: number): Promise<Member> { export async function getUserData(userID: number): Promise<Member> {
const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id = ?`; const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id = ?`;
@@ -60,10 +61,50 @@ export async function getAllMembersLite(): Promise<MemberLight[]> {
return res; return res;
} }
export async function getMembersFull(ids: number[]): Promise<Member[]> { export async function getMembersFull(ids: number[]): Promise<MemberCardDetails[]> {
const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id IN (?);`; const sql = `
const res: Member[] = await pool.query(sql, [ids]); SELECT m.*,
return res; COALESCE(
JSON_ARRAYAGG(
CASE
WHEN r.id IS NOT NULL THEN JSON_OBJECT(
'id', r.id,
'name', r.name,
'color', r.color,
'description', r.description
)
END
),
JSON_ARRAY()
) AS roles
FROM view_member_rank_unit_status_latest m
LEFT JOIN members_roles mr ON m.member_id = mr.member_id
LEFT JOIN roles r ON mr.role_id = r.id
WHERE m.member_id IN (?)
GROUP BY m.member_id;
`;
const rows: any[] = await pool.query(sql, [ids]);
return rows.map(row => {
const member: Member = {
member_id: row.member_id,
member_name: row.member_name,
displayName: row.displayName,
rank: row.rank,
rank_date: row.rank_date,
unit: row.unit,
unit_date: row.unit_date,
status: row.status,
status_date: row.status_date,
loa_until: row.loa_until ? new Date(row.loa_until) : undefined,
};
// roles comes as array of strings; parse each one
const roles: Role[] = JSON.parse(row.roles).map((r: string) => JSON.parse(r));
return { member, roles };
});
} }
export async function mapDiscordtoID(id: number): Promise<number | null> { export async function mapDiscordtoID(id: number): Promise<number | null> {

View File

@@ -1,8 +1,8 @@
import { MemberLight } from '@app/shared/types/member';
import pool from '../db'; import pool from '../db';
import { Role } from '@app/shared/types/roles' import { Role, RoleSummary } from '@app/shared/types/roles'
export async function assignUserGroup(userID: number, roleID: number) { export async function assignUserGroup(userID: number, roleID: number) {
const sql = `INSERT INTO members_roles (member_id, role_id) VALUES (?, ?);`; const sql = `INSERT INTO members_roles (member_id, role_id) VALUES (?, ?);`;
const params = [userID, roleID]; const params = [userID, roleID];
@@ -25,3 +25,33 @@ export async function getUserRoles(userID: number): Promise<Role[]> {
return await pool.query(sql, [userID]); return await pool.query(sql, [userID]);
} }
export async function getRole(id: number): Promise<Role> {
let res = await pool.query(`SELECT * FROM roles WHERE id = ?`, [id])
return res[0] as Role;
}
export async function getAllRoles(): Promise<RoleSummary> {
return await pool.query(`SELECT id, name, color FROM roles`);
}
export async function getUsersWithRole(roleId: number): Promise<MemberLight[]> {
const out = await pool.query(
`
SELECT
m.member_id AS id,
m.member_name AS username,
m.displayName,
u.color
FROM members_roles mr
JOIN view_member_rank_unit_status_latest m
ON m.member_id = mr.member_id
LEFT JOIN units u
ON u.name = m.unit
WHERE mr.role_id = ?
`,
[roleId]
)
return out as MemberLight[]
}

View File

@@ -34,6 +34,11 @@ export interface MemberLight {
color: string color: string
} }
export interface MemberCardDetails {
member: Member;
roles: Role[];
}
export interface myData { export interface myData {
member: Member; member: Member;
LOAs: LOARequest[]; LOAs: LOARequest[];

View File

@@ -1,6 +1,14 @@
import { MemberLight } from "./member";
export interface Role { export interface Role {
id: number; id: number;
name: string; name: string;
color?: string; color?: string;
description?: string; description?: string;
} }
export interface RoleSummary {
id: number;
name: string;
color?: string;
}

View File

@@ -1,4 +1,4 @@
import { memberSettings, Member, MemberLight } from "@shared/types/member"; import { memberSettings, Member, MemberLight, MemberCardDetails } from "@shared/types/member";
// @ts-ignore // @ts-ignore
const addr = import.meta.env.VITE_APIHOST; const addr = import.meta.env.VITE_APIHOST;
@@ -71,7 +71,7 @@ export async function getLightMembers(ids: number[]): Promise<MemberLight[]> {
return response.json(); return response.json();
} }
export async function getFullMembers(ids: number[]): Promise<Member[]> { export async function getFullMembers(ids: number[]): Promise<MemberCardDetails[]> {
if (ids.length === 0) return []; if (ids.length === 0) return [];

View File

@@ -1,10 +1,5 @@
export type Role = { import { Member, MemberLight } from "@shared/types/member";
id: number; import { Role } from "@shared/types/roles";
name: string;
color: string;
description: string | null;
members: any[];
};
// @ts-ignore // @ts-ignore
const addr = import.meta.env.VITE_APIHOST; const addr = import.meta.env.VITE_APIHOST;
@@ -22,6 +17,30 @@ export async function getRoles(): Promise<Role[]> {
} }
} }
export async function getRoleDetails(id: number): Promise<Role> {
const res = await fetch(`${addr}/roles/${id}`, {
credentials: 'include',
})
if (res.ok) {
return res.json() as Promise<Role>;
} else {
throw new Error("Could not load role");
}
}
export async function getRoleMembers(id: number): Promise<MemberLight[]> {
const res = await fetch(`${addr}/roles/${id}/members`, {
credentials: 'include',
})
if (res.ok) {
return res.json();
} else {
throw new Error("Could not load members");
}
}
export async function createRole(name: string, color: string, description: string | null): Promise<Role | null> { export async function createRole(name: string, color: string, description: string | null): Promise<Role | null> {
const res = await fetch(`${addr}/roles`, { const res = await fetch(`${addr}/roles`, {
method: "POST", method: "POST",

View File

@@ -84,7 +84,7 @@ function loaStatus(loa: LOARequest): "Upcoming" | "Active" | "Overdue" | "Closed
if (now < start) return "Upcoming"; if (now < start) return "Upcoming";
if (now >= start && now <= end) return "Active"; if (now >= start && now <= end) return "Active";
if (now > end) return "Overdue"; if (now > loa.extended_till || end) return "Overdue";
return "Overdue"; // fallback return "Overdue"; // fallback
} }
@@ -197,7 +197,7 @@ function setPage(pagenum: number) {
<TableCell @click.stop="" class="text-right"> <TableCell @click.stop="" class="text-right">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer"> <DropdownMenuTrigger class="cursor-pointer">
<Button variant="ghost"> <Button variant="ghost" size="icon">
<Ellipsis class="size-6"></Ellipsis> <Ellipsis class="size-6"></Ellipsis>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -220,10 +220,11 @@ function setPage(pagenum: number) {
</DropdownMenu> </DropdownMenu>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button v-if="expanded === post.id" @click.stop="expanded = null" variant="ghost"> <Button v-if="expanded === post.id" @click.stop="expanded = null" size="icon"
variant="ghost">
<ChevronUp class="size-6" /> <ChevronUp class="size-6" />
</Button> </Button>
<Button v-else @click.stop="expanded = post.id" variant="ghost"> <Button v-else @click.stop="expanded = post.id" size="icon" variant="ghost">
<ChevronDown class="size-6" /> <ChevronDown class="size-6" />
</Button> </Button>
</TableCell> </TableCell>
@@ -233,18 +234,24 @@ function setPage(pagenum: number) {
<TableCell :colspan="8" class="p-0"> <TableCell :colspan="8" class="p-0">
<div class="w-full p-3 mb-6 space-y-3"> <div class="w-full p-3 mb-6 space-y-3">
<div class="flex justify-between items-start gap-4"> <div class="flex justify-between items-start gap-4">
<div class="flex-1"> <div class="space-y-3 w-full">
<!-- Title -->
<p class="text-md font-semibold text-foreground"> <!-- Header -->
Reason <div class="flex items-center gap-2">
</p> <h4 class="text-sm font-semibold text-foreground">
Reason
</h4>
<Separator class="flex-1" />
</div>
<!-- Content --> <!-- Content -->
<p <div
class="mt-1 text-md whitespace-pre-wrap leading-relaxed text-muted-foreground"> class="rounded-lg border bg-muted/40 px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground w-full">
{{ post.reason }} {{ post.reason || 'No reason provided.' }}
</p> </div>
</div> </div>
</div> </div>
</div> </div>
</TableCell> </TableCell>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMemberDirectory } from '@/stores/memberDirectory'; import { useMemberDirectory } from '@/stores/memberDirectory';
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { Member, type MemberLight } from '@shared/types/member' import { Member, MemberCardDetails, type MemberLight } from '@shared/types/member'
import Popover from '../ui/popover/Popover.vue'; import Popover from '../ui/popover/Popover.vue';
import PopoverTrigger from '../ui/popover/PopoverTrigger.vue'; import PopoverTrigger from '../ui/popover/PopoverTrigger.vue';
import PopoverContent from '../ui/popover/PopoverContent.vue'; import PopoverContent from '../ui/popover/PopoverContent.vue';
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils.js'
import { watch } from 'vue'; import { watch } from 'vue';
import { format } from 'path'; import { format } from 'path';
import Spinner from '../ui/spinner/Spinner.vue'; import Spinner from '../ui/spinner/Spinner.vue';
import { Dot } from 'lucide-vue-next';
// Props // Props
@@ -21,7 +22,7 @@ const props = defineProps({
// Local state // Local state
const memberLight = ref<MemberLight | null>(null); const memberLight = ref<MemberLight | null>(null);
const memberFull = ref<Member | null>(null) const memberFull = ref<MemberCardDetails | null>(null)
const loadingFull = ref(false) const loadingFull = ref(false)
const membersStore = useMemberDirectory(); const membersStore = useMemberDirectory();
@@ -63,7 +64,7 @@ const hasFullInfo = computed(() => {
if (!memberFull.value) return false if (!memberFull.value) return false
// check if any field has a value // check if any field has a value
const { rank, unit, status } = memberFull.value const { rank, unit, status } = memberFull.value.member
return !!(rank || unit || status) return !!(rank || unit || status)
}) })
@@ -90,7 +91,7 @@ function formatDate(date: Date): string {
{{ displayName }} {{ displayName }}
</p> </p>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-72 p-0 overflow-hidden"> <PopoverContent class="w-80 p-0 overflow-hidden">
<!-- Loading --> <!-- Loading -->
<div v-if="loadingFull" class="p-4 text-sm text-muted-foreground mx-auto flex justify-center my-5"> <div v-if="loadingFull" class="p-4 text-sm text-muted-foreground mx-auto flex justify-center my-5">
<Spinner></Spinner> <Spinner></Spinner>
@@ -114,26 +115,36 @@ function formatDate(date: Date): string {
<div class="p-4 space-y-3 text-sm"> <div class="p-4 space-y-3 text-sm">
<!-- Full info --> <!-- Full info -->
<template v-if="hasFullInfo"> <template v-if="hasFullInfo">
<div v-if="memberFull.loa_until" <div v-if="memberFull.member.loa_until"
class=" rounded-md text-center bg-yellow-500/10 px-2 py-1 text-xs text-yellow-600"> 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) }} On Leave of Absence until {{ formatDate(memberFull.member.loa_until) }}
</div> </div>
<div v-if="memberFull.rank" class="flex justify-between">
<div v-if="memberFull.member.rank" class="flex justify-between">
<span class="text-muted-foreground">Rank</span> <span class="text-muted-foreground">Rank</span>
<span class="font-medium">{{ memberFull.rank }}</span> <span class="font-medium">{{ memberFull.member.rank }}</span>
</div> </div>
<div v-if="memberFull.unit" class="flex justify-between"> <div v-if="memberFull.member.unit" class="flex justify-between items-center">
<span class="text-muted-foreground">Unit</span> <span class="text-muted-foreground">Unit</span>
<span class="font-medium">{{ memberFull.unit }}</span> <span class="font-medium flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: textColor }"></span>
{{ memberFull.member.unit }}
</span>
</div> </div>
<div v-if="memberFull.status" class="flex justify-between"> <div v-if="memberFull.member.status" class="flex justify-between">
<span class="text-muted-foreground">Status</span> <span class="text-muted-foreground">Status</span>
<span class="font-medium">{{ memberFull.status }}</span> <span class="font-medium">{{ memberFull.member.status }}</span>
</div> </div>
<div class="flex gap-2 flex-wrap mt-6">
<div v-for="role in memberFull.roles" class="border rounded-full px-3 text-nowrap">
{{ role.name }}
</div>
</div>
</template> </template>
<!-- No info fallback --> <!-- No info fallback -->

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { addMemberToRole, removeMemberFromRole } from '@/api/roles';
import { MemberLight } from '@shared/types/member';
import { Role } from '@shared/types/roles';
import { computed, ref } from 'vue';
import Dialog from '../ui/dialog/Dialog.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 DialogFooter from '../ui/dialog/DialogFooter.vue'
import Button from '../ui/button/Button.vue';
import InputGroup from '../ui/input-group/InputGroup.vue';
import InputGroupAddon from '../ui/input-group/InputGroupAddon.vue';
import { SearchIcon } from 'lucide-vue-next';
const props = defineProps<{
allMembers: MemberLight[],
role: Role
}>()
const emit = defineEmits(['submit'])
const showAddMemberDialog = ref(false)
const memberToAdd = ref<MemberLight | null>(null)
const searchQuery = ref('')
const filteredMembers = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return props.allMembers
return props.allMembers.filter(m =>
m.displayName?.toLowerCase().includes(q) ||
m.username?.toLowerCase().includes(q)
)
})
defineExpose({
openDialog,
})
function openDialog() {
showAddMemberDialog.value = true;
}
async function handleAddMember() {
//guard
if (memberToAdd.value == null)
return;
await addMemberToRole(memberToAdd.value.id, props.role.id);
emit('submit');
showAddMemberDialog.value = false;
}
</script>
<template>
<Dialog v-model:open="showAddMemberDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add member to {{ props.role?.name }}</DialogTitle>
<DialogDescription>
Search for a member to add to this group.
</DialogDescription>
</DialogHeader>
<!-- Search -->
<InputGroup>
<InputGroupAddon>
<SearchIcon class="h-4 w-4 text-muted-foreground" />
</InputGroupAddon>
<input v-model="searchQuery" type="text" placeholder="Search members…"
class="flex-1 bg-transparent outline-none text-sm" />
</InputGroup>
<!-- Results -->
<div class="mt-3 h-64 overflow-y-auto scrollbar-themed rounded-md border">
<ul class="divide-y">
<li v-for="member in filteredMembers" :key="member.id" @click="memberToAdd = member"
class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-muted"
:class="memberToAdd?.id === member.id && 'bg-muted'">
<span>{{ member.displayName || member.username }}</span>
<span v-if="memberToAdd?.id === member.id" class="text-xs text-muted-foreground">
Selected
</span>
</li>
</ul>
</div>
<DialogFooter>
<Button variant="secondary" @click="showAddMemberDialog = false">
Cancel
</Button>
<Button :disabled="!memberToAdd" @click="handleAddMember">
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { addMemberToRole, getRoleDetails, getRoleMembers, removeMemberFromRole } from '@/api/roles'
import type { MemberLight } from '@shared/types/member'
import type { Role } from '@shared/types/roles'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import Separator from '@/components/ui/separator/Separator.vue'
import { Plus, SearchIcon, X } from 'lucide-vue-next'
import MemberCard from '../members/MemberCard.vue'
import InputGroup from '../ui/input-group/InputGroup.vue'
import InputGroupAddon from '../ui/input-group/InputGroupAddon.vue'
import AddMember from './addMember.vue'
const route = useRoute()
const roleData = ref<Role | null>(null)
const roleMembers = ref<MemberLight[]>([])
const loading = ref(true)
async function loadRole() {
const id = Number(route.params.id)
roleData.value = await getRoleDetails(id)
roleMembers.value = await getRoleMembers(id)
loading.value = false
}
const searchQuery = ref('')
const roleMembersFiltered = computed(() => {
if (!searchQuery.value) return roleMembers.value
const query = searchQuery.value.toLowerCase()
return roleMembers.value.filter(member =>
member.displayName?.toLowerCase().includes(query) ||
member.username?.toLowerCase().includes(query)
)
})
const props = defineProps<{
allMembers: MemberLight[]
}>()
const availableMembers = computed(() =>
props.allMembers.filter(
m => !roleMembers.value.some(rm => rm.id === m.id)
)
)
async function handleRemoveMember(memberId: number) {
await removeMemberFromRole(memberId, Number(route.params.id));
await loadRole();
}
const addMemberRef = ref<InstanceType<typeof AddMember> | null>(null)
onMounted(loadRole)
watch(() => route.params.id, loadRole)
</script>
<template>
<AddMember ref="addMemberRef" :all-members="availableMembers" :role="roleData" @submit="loadRole"></AddMember>
<div class="h-full px-6 py-2">
<!-- Loading -->
<div v-if="loading" class="text-muted-foreground">
Loading group
</div>
<!-- No role selected -->
<div v-else-if="!roleData" class="text-muted-foreground">
Select a group to view details
</div>
<!-- Role details -->
<div v-else class="space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<div class="space-y-1">
<div class="flex items-center gap-3">
<span class="h-3 w-3 rounded-full" :style="{ backgroundColor: roleData.color }" />
<h2 class="text-2xl font-semibold tracking-tight">
{{ roleData.name }}
</h2>
</div>
<p class="text-sm text-muted-foreground">
{{ roleData.description || 'No description provided.' }}
</p>
</div>
<!-- <Button variant="ghost" size="sm" class="text-destructive">
Delete
</Button> -->
</div>
<Separator />
<!-- Members -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium">
Members ({{ roleMembers.length }})
</h3>
<div class="flex items-center gap-5">
<InputGroup class="w-64">
<InputGroupAddon>
<SearchIcon class="h-4 w-4 text-muted-foreground" />
</InputGroupAddon>
<input v-model="searchQuery" type="text" placeholder="Search members…"
class="flex-1 bg-transparent outline-none text-sm" />
</InputGroup>
<Button variant="secondary" @click="addMemberRef.openDialog()">
<Plus class="mr-2 h-4 w-4" />
Add Member
</Button>
</div>
</div>
<!-- Empty state -->
<div v-if="roleMembers.length === 0" class="text-sm text-muted-foreground py-6 text-center">
No members in this group yet.
</div>
<div class="overflow-y-auto max-h-[55dvh] pr-1 scrollbar-themed">
<ul class="space-y-1">
<li v-for="member in roleMembersFiltered" :key="member.id"
class="flex items-center justify-between rounded-md px-3 py-2 hover:bg-muted/50">
<MemberCard :member-id="member.id" />
<Button variant="ghost" size="icon" class="text-muted-foreground"
@click="handleRemoveMember(member.id)">
<X class="h-4 w-4" />
</Button>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
defineProps<{
open: boolean,
message: string
}>();
</script>
<template>
<div class="relative inline-flex items-center group w-min">
<slot></slot>
<div v-if="open" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
text-popover-foreground shadow-md border border-border
opacity-0 translate-y-1
group-hover:opacity-100 group-hover:translate-y-0
transition-opacity transition-transform duration-150">
{{ message }}
</div>
</div>
</template>

View File

@@ -25,6 +25,7 @@ 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 Combobox from '../ui/combobox/Combobox.vue' import Combobox from '../ui/combobox/Combobox.vue'
import Tooltip from '../tooltip/Tooltip.vue'
const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({ const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({
@@ -50,7 +51,9 @@ watch(() => values.course_id, (newCourseId, oldCourseId) => {
if (!oldCourseId) return; if (!oldCourseId) return;
values.attendees.forEach((a, index) => { values.attendees.forEach((a, index) => {
// @ts-ignore
setFieldValue(`attendees[${index}].passed_bookwork`, false); setFieldValue(`attendees[${index}].passed_bookwork`, false);
// @ts-ignore
setFieldValue(`attendees[${index}].passed_qual`, false); setFieldValue(`attendees[${index}].passed_qual`, false);
}); });
}); });
@@ -326,22 +329,13 @@ const filteredMembers = computed(() => {
<VeeField v-slot="{ field }" :name="`attendees[${index}].passed_bookwork`" type="checkbox" <VeeField v-slot="{ field }" :name="`attendees[${index}].passed_bookwork`" type="checkbox"
:value="false" :unchecked-value="true"> :value="false" :unchecked-value="true">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div class="relative inline-flex items-center group"> <Tooltip :open="!selectedCourse?.hasBookwork"
message="This course does not have bookwork">
<Checkbox :disabled="!selectedCourse?.hasBookwork" <Checkbox :disabled="!selectedCourse?.hasBookwork"
:name="`attendees[${index}].passed_bookwork`" :model-value="!field.checked" :name="`attendees[${index}].passed_bookwork`" :model-value="!field.checked"
@update:model-value="field['onUpdate:modelValue']"> @update:model-value="field['onUpdate:modelValue']">
</Checkbox> </Checkbox>
<!-- Tooltip bubble --> </Tooltip>
<div v-if="!selectedCourse?.hasBookwork" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
text-popover-foreground shadow-md border border-border
opacity-0 translate-y-1
group-hover:opacity-100 group-hover:translate-y-0
transition-opacity transition-transform duration-150">
This course does not have bookwork
</div>
</div>
<div class="h-4"> <div class="h-4">
</div> </div>
</div> </div>
@@ -351,20 +345,12 @@ const filteredMembers = computed(() => {
<VeeField v-slot="{ field }" :name="`attendees[${index}].passed_qual`" type="checkbox" <VeeField v-slot="{ field }" :name="`attendees[${index}].passed_qual`" type="checkbox"
:value="false" :unchecked-value="true"> :value="false" :unchecked-value="true">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div class="relative inline-flex items-center group"> <Tooltip :open="!selectedCourse?.hasQual"
message="This course does not have a qualification">
<Checkbox :disabled="!selectedCourse?.hasQual" <Checkbox :disabled="!selectedCourse?.hasQual"
:name="`attendees[${index}].passed_qual`" :model-value="!field.checked" :name="`attendees[${index}].passed_qual`" :model-value="!field.checked"
@update:model-value="field['onUpdate:modelValue']"></Checkbox> @update:model-value="field['onUpdate:modelValue']"></Checkbox>
<!-- Tooltip bubble --> </Tooltip>
<div v-if="!selectedCourse?.hasQual" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
text-popover-foreground shadow-md border border-border
opacity-0 translate-y-1
group-hover:opacity-100 group-hover:translate-y-0
transition-opacity transition-transform duration-150">
This course does not have a qualification
</div>
</div>
<div class="h-4"> <div class="h-4">
</div> </div>
</div> </div>

View File

@@ -2,10 +2,10 @@ import { cva } from "class-variance-authority";
export { default as InputGroup } from "./InputGroup.vue"; export { default as InputGroup } from "./InputGroup.vue";
export { default as InputGroupAddon } from "./InputGroupAddon.vue"; export { default as InputGroupAddon } from "./InputGroupAddon.vue";
export { default as InputGroupButton } from "./InputGroupButton.vue"; // export { default as InputGroupButton } from "./InputGroupButton.vue";
export { default as InputGroupInput } from "./InputGroupInput.vue"; // export { default as InputGroupInput } from "./InputGroupInput.vue";
export { default as InputGroupText } from "./InputGroupText.vue"; // export { default as InputGroupText } from "./InputGroupText.vue";
export { default as InputGroupTextarea } from "./InputGroupTextarea.vue"; // export { default as InputGroupTextarea } from "./InputGroupTextarea.vue";
export const inputGroupAddonVariants = cva( export const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",

View File

@@ -22,7 +22,7 @@ const modelValue = useVModel(props, "modelValue", emits, {
data-slot="input" data-slot="input"
:class=" :class="
cn( cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', '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', 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class, props.class,

View File

@@ -20,6 +20,7 @@ const decisionDate = ref<Date | null>(null);
const submitDate = ref<Date | null>(null); const submitDate = ref<Date | null>(null);
const loading = ref<boolean>(true); const loading = ref<boolean>(true);
const member_name = ref<string>(); const member_name = ref<string>();
const notFound = ref<boolean>(false);
const props = defineProps<{ const props = defineProps<{
mode?: "create" | "view-self" | "view-recruiter" | "view-self-id" mode?: "create" | "view-self" | "view-recruiter" | "view-self-id"
@@ -29,6 +30,11 @@ const finalMode = ref<"create" | "view-self" | "view-recruiter" | "view-self-id"
function loadData(raw: ApplicationFull) { function loadData(raw: ApplicationFull) {
if (!raw) {
notFound.value = true;
return;
}
const data = raw.application; const data = raw.application;
appID.value = data.id; appID.value = data.id;
@@ -129,6 +135,10 @@ async function handleDeny(id) {
<div v-if="unauthorized" class="flex justify-center w-full my-10"> <div v-if="unauthorized" class="flex justify-center w-full my-10">
You do not have permission to view this application. You do not have permission to view this application.
</div> </div>
<div v-else-if="notFound" class="flex justify-center w-full my-10 text-muted-foreground">
Looks like you dont have an application, reach out to the administration team if you believe this is an
error.
</div>
<div v-else> <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 -->
@@ -181,8 +191,7 @@ async function handleDeny(id) {
</div> </div>
</div> </div>
<!-- TODO: Implement some kinda loading screen -->
<div v-else class="flex items-center justify-center h-full"> <div v-else class="flex items-center justify-center h-full">
<Spinner class="size-8"/> <Spinner class="size-8" />
</div> </div>
</template> </template>

View File

@@ -9,7 +9,7 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { onMounted, ref, computed, reactive, watch } from 'vue'; import { onMounted, ref, computed, reactive, watch } from 'vue';
import { addMemberToRole, createRole, deleteRole, getRoles, removeMemberFromRole, Role } from '@/api/roles'; import { addMemberToRole, createRole, deleteRole, getRoles, removeMemberFromRole } from '@/api/roles';
import Badge from '@/components/ui/badge/Badge.vue'; import Badge from '@/components/ui/badge/Badge.vue';
import { import {
Dialog, Dialog,
@@ -34,8 +34,11 @@ import { Plus, X } from 'lucide-vue-next';
import Separator from '@/components/ui/separator/Separator.vue'; import Separator from '@/components/ui/separator/Separator.vue';
import Input from '@/components/ui/input/Input.vue'; import Input from '@/components/ui/input/Input.vue';
import Label from '@/components/ui/label/Label.vue'; import Label from '@/components/ui/label/Label.vue';
import { getMembers } from '@/api/member'; import { getAllLightMembers, getMembers } from '@/api/member';
import { Member } from '@shared/types/member'; import { Member, MemberLight } from '@shared/types/member';
import { Role } from '@shared/types/roles';
import RoleView from '@/components/roles/roleView.vue';
import { useRoute } from 'vue-router';
const roles = ref<Role[]>([]) const roles = ref<Role[]>([])
const activeRole = ref<Role | null>(null) const activeRole = ref<Role | null>(null)
@@ -43,16 +46,9 @@ const showDialog = ref(false);
const showCreateGroupDialog = ref(false); const showCreateGroupDialog = ref(false);
const addingMember = ref(false); const addingMember = ref(false);
const memberToAdd = ref<Member | null>(null); const memberToAdd = ref<Member | null>(null);
const route = useRoute();
const allMembers = ref<Member[]>([]) const allMembers = ref<MemberLight[]>([])
const availableMembers = computed(() => {
if (!activeRole.value) return [];
return allMembers.value.filter(
member => !activeRole.value!.members.some(
roleMember => roleMember.member_id === member.member_id
)
);
})
type RoleDraft = { type RoleDraft = {
name: string name: string
@@ -117,141 +113,40 @@ async function handleCreateGroup() {
} }
} }
async function handleAddMember() {
//guard
if (memberToAdd.value == null)
return;
await addMemberToRole(memberToAdd.value.member_id, activeRole.value.id);
roles.value = await getRoles();
}
async function handleRemoveMember(memberId: number) {
removeMemberFromRole(memberId, activeRole.value.id);
roles.value = await getRoles();
}
async function handleDeleteRole() {
await deleteRole(activeRole.value.id);
}
onMounted(async () => { onMounted(async () => {
roles.value = await getRoles(); roles.value = await getRoles();
allMembers.value = await getMembers(); allMembers.value = await getAllLightMembers();
}) })
</script> </script>
<template> <template>
<div> <div class="max-w-7xl mx-auto w-full my-[0.2dvh] h-[0.8dvh]">
<Dialog v-model:open="showDialog" <div class="flex items-center justify-between">
v-on:update:open="() => { showDialog = false; addingMember = false; memberToAdd = null; }"> <p class="scroll-m-20 text-2xl font-semibold tracking-tight my-5">Role Management</p>
<DialogContent> </div>
<DialogHeader> <div class="grid grid-cols-[minmax(400px,280px)_1fr] gap-5">
<DialogTitle class="flex justify-between items-center">{{ activeRole.name }} <div class="w-full pr-5 border-r">
<Badge class="mr-5"> <div class="space-y-1">
{{ activeRole.color }} <p class="px-3 py-2 text-xs font-medium text-muted-foreground uppercase">
</Badge> Roles
</DialogTitle> </p>
<DialogDescription>
{{ activeRole.description }} <button v-for="r in roles" :key="r.id" @click="$router.push(`/administration/roles/${r.id}`)"
</DialogDescription> :class="[
</DialogHeader> 'flex w-full items-center gap-3 rounded-md px-3 py-2 transition-colors cursor-pointer',
<div class="mt-5"> Number(route.params.id) == r.id
<div class="flex justify-between items-center"> ? 'bg-muted font-medium'
<p>Members with this role</p> : 'hover:bg-muted/50'
</div> ]">
<Separator class="my-2"></Separator> <span class="h-2 w-2 rounded-full shrink-0" :style="{ backgroundColor: r.color }" />
<ul class="flex flex-col gap-2"> <span class="truncate">{{ r.name }}</span>
<li v-for="member in activeRole.members" class="flex justify-between items-center"> </button>
<p>{{ member.member_name }}</p>
<X class="text-muted-foreground" @click="handleRemoveMember(member.member_id)"></X>
</li>
<div v-if="!addingMember" @click="addingMember = true"
class="flex gap-2 text-muted-foreground hover:text-primary hover:cursor-pointer">
<Plus :size="20"></Plus>Add Member
</div>
<div v-else class="flex gap-2">
<Combobox v-model="memberToAdd" by="value">
<ComboboxAnchor>
<div class="relative w-full max-w-sm items-center">
<ComboboxInput class="pl-9"
:display-value="(member: Member) => member?.member_name"
placeholder="Search Members" />
</div>
</ComboboxAnchor>
<ComboboxList>
<ComboboxEmpty>
No Members.
</ComboboxEmpty>
<ComboboxGroup>
<ComboboxItem v-for="member in availableMembers" :key="member.member_id"
:value="member">
{{ member.member_name }}
</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<Button variant="secondary" @click="addingMember = false">Cancel</Button>
<Button @click="handleAddMember">Save</Button>
</div>
</ul>
</div> </div>
<DialogFooter>
<!-- <Button @click="handleDeleteRole">Delete Group</Button> -->
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showCreateGroupDialog" v-on:update:open="() => { showCreateGroupDialog = false; }">
<DialogContent>
<DialogHeader>
<DialogTitle class="flex justify-between items-center">Create Group</DialogTitle>
<DialogDescription>Create a new group of members</DialogDescription>
</DialogHeader>
<form class="mt-5 space-y-5" @submit.prevent="handleCreateGroup">
<div>
<Label class="mb-2 block" for="group-name">Group Name</Label>
<Input id="group-name" v-model="roleDraft.name" :aria-invalid="!!draftErrors.name" />
<p v-if="draftErrors.name" class="text-destructive text-sm mt-1">{{ draftErrors.name }}</p>
</div>
<div>
<Label class="mb-2 block" for="group-desc">Description</Label>
<Input id="group-desc" v-model="roleDraft.description" />
</div>
<div>
<Label class="mb-2 block" for="group-color">Color</Label>
<Input id="group-color" type="color" v-model="roleDraft.color" />
</div>
<DialogFooter>
<Button type="button" variant="secondary" @click="showCreateGroupDialog = false"
:disabled="creating">
Cancel
</Button>
<Button type="submit" :disabled="creating">
{{ creating ? "Saving..." : "Save" }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<div class="max-w-5xl mx-auto">
<div class="flex items-center justify-between my-4">
<p>Groups</p>
<!-- <Button @click="showCreateGroupDialog = true">+ Add New Group</Button> -->
</div> </div>
<div class="grid grid-cols-3 gap-5"> <div>
<Card v-for="value in roles" :key="value.id" @click="activeRole = value; showDialog = true" <RoleView :all-members="allMembers"></RoleView>
class="cursor-pointer">
<CardHeader>
<CardTitle class="flex justify-between items-center">{{ value.name }}
<Badge>
{{ value.color }}
</Badge>
</CardTitle>
<CardDescription>{{ value.description }}</CardDescription>
</CardHeader>
<CardFooter>
<p class="text-muted-foreground">{{ value.members.length }} members</p>
</CardFooter>
</Card>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -10,7 +10,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { ArrowUpDown, Funnel, Plus, Search, X } from 'lucide-vue-next'; import { ArrowUpDown, ChevronDown, ChevronUp, Funnel, Plus, Search, X } from 'lucide-vue-next';
import Button from '@/components/ui/button/Button.vue'; import Button from '@/components/ui/button/Button.vue';
import TrainingReportForm from '@/components/trainingReport/trainingReportForm.vue'; import TrainingReportForm from '@/components/trainingReport/trainingReportForm.vue';
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'; import Checkbox from '@/components/ui/checkbox/Checkbox.vue';
@@ -32,6 +32,7 @@ import {
PaginationNext, PaginationNext,
PaginationPrevious, PaginationPrevious,
} from '@/components/ui/pagination' } from '@/components/ui/pagination'
import Tooltip from '@/components/tooltip/Tooltip.vue';
enum sidePanelState { view, create, closed }; enum sidePanelState { view, create, closed };
@@ -53,6 +54,7 @@ watch(() => route.params.id, async (newID) => {
return; return;
} }
TRLoaded.value = false; TRLoaded.value = false;
expanded.value = null;
viewTrainingReport(Number(route.params.id)); viewTrainingReport(Number(route.params.id));
}) })
@@ -126,6 +128,8 @@ function setPage(pagenum: number) {
pageNum.value = pagenum; pageNum.value = pagenum;
loadTrainingReports(); loadTrainingReports();
} }
const expanded = ref<number>(null);
</script> </script>
<template> <template>
@@ -197,8 +201,8 @@ function setPage(pagenum: number) {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<div class="mt-5 flex justify-between" :class="sidePanel !== sidePanelState.closed ? 'flex-col items-center' : 'items-center justify-between'" <div class="mt-5 flex justify-between"
> :class="sidePanel !== sidePanelState.closed ? 'flex-col items-center' : 'items-center justify-between'">
<div></div> <div></div>
<Pagination v-slot="{ page }" :items-per-page="pageData?.pageSize || 10" :total="pageData?.total || 10" <Pagination v-slot="{ page }" :items-per-page="pageData?.pageSize || 10" :total="pageData?.total || 10"
:default-page="2" :page="pageNum" @update:page="setPage"> :default-page="2" :page="pageNum" @update:page="setPage">
@@ -214,7 +218,7 @@ function setPage(pagenum: number) {
<PaginationNext /> <PaginationNext />
</PaginationContent> </PaginationContent>
</Pagination> </Pagination>
<div class="flex items-center gap-3 text-sm" :class="sidePanel !== sidePanelState.closed ? 'mt-3' : ''" > <div class="flex items-center gap-3 text-sm" :class="sidePanel !== sidePanelState.closed ? 'mt-3' : ''">
<p class="text-muted-foreground text-nowrap">Per page:</p> <p class="text-muted-foreground text-nowrap">Per page:</p>
<button v-for="size in pageSizeOptions" :key="size" @click="setPageSize(size)" <button v-for="size in pageSizeOptions" :key="size" @click="setPageSize(size)"
@@ -255,89 +259,180 @@ function setPage(pagenum: number) {
<!-- Trainers --> <!-- Trainers -->
<div> <div>
<label class="scroll-m-20 text-xl font-semibold tracking-tight">Trainers</label> <label class="scroll-m-20 text-xl font-semibold tracking-tight">Trainers</label>
<div class="grid grid-cols-4 py-2 text-sm font-medium text-muted-foreground border-b"> <div
class="grid grid-cols-[1fr_1fr_2fr_3rem] py-2 text-sm font-medium text-muted-foreground border-b">
<span>Name</span> <span>Name</span>
<span class="">Role</span> <span class="">Role</span>
<span class="text-right col-span-2">Remarks</span> <span class="text-right">Remarks</span>
<span></span>
</div> </div>
<div v-for="person in focusedTrainingTrainers" <div v-for="person in focusedTrainingTrainers" class=" items-center border-b last:border-none"
class="grid grid-cols-4 py-2 items-center border-b last:border-none"> :class="expanded === person.attendee_id && 'bg-muted/20'">
<div> <div class="grid grid-cols-[1fr_1fr_2fr_3rem] items-center py-2">
<MemberCard v-if="person.attendee_id" :member-id="person.attendee_id" <div>
class="justify-self-start"></MemberCard> <MemberCard v-if="person.attendee_id" :member-id="person.attendee_id"
<p v-else>{{ person.attendee_name }}</p> class="justify-self-start"></MemberCard>
<p v-else>{{ person.attendee_name }}</p>
</div>
<p class="">{{ person.role.name }}</p>
<p class="text-right px-2 truncate"
:class="person.remarks == '' ? 'text-muted-foreground' : ''">
{{ person.remarks == "" ?
'--'
: person.remarks }}</p>
<div class="w-min mx-auto">
<Button v-if="expanded === person.attendee_id" @click.stop="expanded = null"
variant="ghost" size="icon">
<ChevronUp class="size-6" />
</Button>
<Button v-else @click.stop="expanded = person.attendee_id" variant="ghost"
size="icon">
<ChevronDown class="size-6" />
</Button>
</div>
</div>
<div class="col-span-full" v-if="expanded === person.attendee_id">
<div class="px-6 pb-5 pt-2 space-y-2">
<p class="text-sm font-medium text-foreground">
Remarks
</p>
<p v-if="person.remarks"
class="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap max-h-72 overflow-y-auto">
{{ person.remarks }}
</p>
<p v-else class="text-sm text-muted-foreground italic">
None provided
</p>
</div>
</div> </div>
<p class="">{{ person.role.name }}</p>
<p class="col-span-2 text-right px-2"
:class="person.remarks == '' ? 'text-muted-foreground' : ''">
{{ person.remarks == "" ?
'--'
: person.remarks }}</p>
</div> </div>
</div> </div>
<!-- trainees --> <!-- trainees -->
<div> <div>
<div class="flex flex-col"> <div class="flex flex-col">
<label class="scroll-m-20 text-xl font-semibold tracking-tight">Trainees</label> <label class="scroll-m-20 text-xl font-semibold tracking-tight">Trainees</label>
<div class="grid grid-cols-5 py-2 text-sm font-medium text-muted-foreground border-b"> <div
class="grid grid-cols-[1fr_5rem_5rem_2fr_3rem] py-2 text-sm font-medium text-muted-foreground border-b px-2">
<span>Name</span> <span>Name</span>
<span class="">Bookwork</span> <span class="text-center">Bookwork</span>
<span class="">Qual</span> <span class="text-center">Qual</span>
<span class="text-right col-span-2">Remarks</span> <span class="text-right">Remarks</span>
<span class="w-15"></span>
</div> </div>
</div> </div>
<div v-for="person in focusedTrainingTrainees" <div v-for="person in focusedTrainingTrainees" class="border-b last:border-none"
class="grid grid-cols-5 py-2 items-center border-b last:border-none"> :class="expanded === person.attendee_id && 'bg-muted/20'">
<div> <div class="grid grid-cols-[1fr_5rem_5rem_2fr_3rem] py-2 items-center mx-2">
<MemberCard v-if="person.attendee_id" :member-id="person.attendee_id" <div>
class="justify-self-start"></MemberCard> <MemberCard v-if="person.attendee_id" :member-id="person.attendee_id"
<p v-else>{{ person.attendee_name }}</p> class="justify-self-start"></MemberCard>
<p v-else>{{ person.attendee_name }}</p>
</div>
<Tooltip :open="!focusedTrainingReport.course.hasBookwork" class="mx-auto"
message="This course does not have bookwork">
<Checkbox :disabled="!focusedTrainingReport.course.hasBookwork"
:model-value="person.passed_bookwork" class="pointer-events-none">
</Checkbox>
</Tooltip>
<Tooltip :open="!focusedTrainingReport.course.hasQual" class="mx-auto"
message="This course does not have a qualification">
<Checkbox :disabled="!focusedTrainingReport.course.hasQual"
:model-value="person.passed_qual" class="pointer-events-none ml-1">
</Checkbox>
</Tooltip>
<p class="text-right truncate"
:class="person.remarks == '' ? 'text-muted-foreground' : ''">
{{ person.remarks == "" ?
'--'
: person.remarks }}</p>
<div class="w-min mx-auto">
<Button v-if="expanded === person.attendee_id" @click.stop="expanded = null"
variant="ghost" size="icon">
<ChevronUp class="size-6" />
</Button>
<Button v-else @click.stop="expanded = person.attendee_id" variant="ghost"
size="icon">
<ChevronDown class="size-6" />
</Button>
</div>
</div>
<div class="col-span-full" v-if="expanded === person.attendee_id">
<div class="px-6 pb-5 pt-2 space-y-2">
<p class="text-sm font-medium text-foreground">
Remarks
</p>
<p v-if="person.remarks"
class="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap max-h-72 overflow-y-auto">
{{ person.remarks }}
</p>
<p v-else class="text-sm text-muted-foreground italic">
None provided
</p>
</div>
</div> </div>
<Checkbox :disabled="!focusedTrainingReport.course.hasQual"
:model-value="person.passed_bookwork" class="pointer-events-none ml-5">
</Checkbox>
<Checkbox :disabled="!focusedTrainingReport.course.hasQual"
:model-value="person.passed_qual" class="pointer-events-none ml-1">
</Checkbox>
<p class="col-span-2 text-right px-2"
:class="person.remarks == '' ? 'text-muted-foreground' : ''">
{{ person.remarks == "" ?
'--'
: person.remarks }}</p>
</div> </div>
</div> </div>
<!-- No Shows --> <!-- No Shows -->
<div v-if="focusedNoShows.length != 0"> <div v-if="focusedNoShows.length != 0">
<div class="flex flex-col"> <div class="flex flex-col">
<label class="scroll-m-20 text-xl font-semibold tracking-tight">No Shows</label> <label class="scroll-m-20 text-xl font-semibold tracking-tight">
<div class="grid grid-cols-5 py-2 text-sm font-medium text-muted-foreground border-b"> No Shows
</label>
<div
class="grid grid-cols-[1fr_5rem_5rem_2fr_3rem] py-2 text-sm font-medium text-muted-foreground border-b">
<span>Name</span> <span>Name</span>
<!-- <span class="">Role</span> <span></span>
<span class="">Role</span> --> <span></span>
<div></div> <span class="text-right">Remarks</span>
<div></div> <span></span>
<span class="text-right col-span-2">Remarks</span>
</div> </div>
</div> </div>
<div v-for="person in focusedNoShows"
class="grid grid-cols-5 py-2 items-center border-b last:border-none"> <div v-for="person in focusedNoShows" :key="person.attendee_id ?? person.attendee_name"
<div> class="border-b last:border-none transition-colors"
<MemberCard v-if="person.attendee_id" :member-id="person.attendee_id" :class="expanded === person.attendee_id && 'bg-muted/20'">
class="justify-self-start"></MemberCard> <!-- Row -->
<p v-else>{{ person.attendee_name }}</p> <div class="grid grid-cols-[1fr_5rem_5rem_2fr_3rem] py-2 items-center">
<!-- Name -->
<div>
<MemberCard v-if="person.attendee_id" :member-id="person.attendee_id" />
<p v-else>{{ person.attendee_name }}</p>
</div>
<div></div>
<div></div>
<p class="text-right px-2 truncate" :class="!person.remarks && 'text-muted-foreground'">
{{ person.remarks || '--' }}
</p>
<Button variant="ghost" size="icon" @click.stop="expanded === person.attendee_id
? expanded = null
: expanded = person.attendee_id">
<ChevronDown v-if="expanded !== person.attendee_id" class="size-5" />
<ChevronUp v-else class="size-5" />
</Button>
</div>
<div v-if="expanded === person.attendee_id" class="col-span-full">
<div class="px-6 py-4 space-y-2">
<p class="text-sm font-medium text-foreground">
Remarks
</p>
<p v-if="person.remarks"
class="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap max-h-72 overflow-y-auto">
{{ person.remarks }}
</p>
<p v-else class="text-sm text-muted-foreground italic">
None provided
</p>
</div>
</div> </div>
<!-- <Checkbox :default-value="person.passed_bookwork ? true : false" class="pointer-events-none">
</Checkbox>
<Checkbox :default-value="person.passed_qual ? true : false" class="pointer-events-none">
</Checkbox> -->
<div></div>
<div></div>
<p class="col-span-2 text-right px-2"
:class="person.remarks == '' ? 'text-muted-foreground' : ''">
{{ person.remarks == "" ?
'--'
: person.remarks }}</p>
</div> </div>
</div> </div>
<div> <div>
<label class="scroll-m-20 text-xl font-semibold tracking-tight">Remarks</label> <label class="scroll-m-20 text-xl font-semibold tracking-tight">Remarks</label>
@@ -368,33 +463,3 @@ function setPage(pagenum: number) {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
/* Firefox */
.scrollbar-themed {
scrollbar-width: thin;
scrollbar-color: #555 #1f1f1f;
padding-right: 6px;
}
/* Chrome, Edge, Safari */
.scrollbar-themed::-webkit-scrollbar {
width: 10px;
/* slightly wider to allow padding look */
}
.scrollbar-themed::-webkit-scrollbar-track {
background: #1f1f1f;
margin-left: 6px;
/* ❗ adds space between content + scrollbar */
}
.scrollbar-themed::-webkit-scrollbar-thumb {
background: #555;
border-radius: 9999px;
}
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>

View File

@@ -40,7 +40,8 @@ const router = createRouter({
{ path: 'applications/:id', component: () => import('@/pages/Application.vue') }, { path: 'applications/:id', component: () => import('@/pages/Application.vue') },
{ path: 'transfer', component: () => import('@/pages/ManageTransfers.vue') }, { path: 'transfer', component: () => import('@/pages/ManageTransfers.vue') },
{ path: 'loa', component: () => import('@/pages/ManageLOA.vue') }, { path: 'loa', component: () => import('@/pages/ManageLOA.vue') },
{ path: 'roles', component: () => import('@/pages/ManageRoles.vue') } { path: 'roles', component: () => import('@/pages/ManageRoles.vue') },
{ path: 'roles/:id', component: () => import('@/pages/ManageRoles.vue') }
] ]
}, },

View File

@@ -1,5 +1,5 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import type { MemberLight, Member } from "@shared/types/member" import type { MemberLight, Member, MemberCardDetails } from "@shared/types/member"
import { getLightMembers, getFullMembers } from "@/api/member" import { getLightMembers, getFullMembers } from "@/api/member"
import { reactive, ref } from "vue" import { reactive, ref } from "vue"
import { resolve } from "path" import { resolve } from "path"
@@ -7,7 +7,7 @@ import { rejects } from "assert"
export const useMemberDirectory = defineStore('memberDirectory', () => { export const useMemberDirectory = defineStore('memberDirectory', () => {
const light = reactive<Record<number, MemberLight>>({}); const light = reactive<Record<number, MemberLight>>({});
const full = reactive<Record<number, Member>>({}) const full = reactive<Record<number, MemberCardDetails>>({})
function getLight(id: number): Promise<MemberLight> { function getLight(id: number): Promise<MemberLight> {
if (light[id]) return Promise.resolve(light[id]); if (light[id]) return Promise.resolve(light[id]);
@@ -24,7 +24,7 @@ export const useMemberDirectory = defineStore('memberDirectory', () => {
}) })
} }
function getFull(id: number): Promise<Member> { function getFull(id: number): Promise<MemberCardDetails> {
if (full[id]) return Promise.resolve(full[id]) if (full[id]) return Promise.resolve(full[id])
if (!fullWaiters.has(id)) { if (!fullWaiters.has(id)) {
@@ -34,7 +34,7 @@ export const useMemberDirectory = defineStore('memberDirectory', () => {
scheduleBatch() scheduleBatch()
return new Promise<Member>((resolve, reject) => { return new Promise<MemberCardDetails>((resolve, reject) => {
fullWaiters.get(id)!.push({ resolve, reject }) fullWaiters.get(id)!.push({ resolve, reject })
}) })
} }
@@ -50,7 +50,7 @@ export const useMemberDirectory = defineStore('memberDirectory', () => {
// promises // promises
const lightWaiters = new Map<number, Array<{ resolve: (m: MemberLight) => void; reject: (e: any) => void }>>() 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 }>>() const fullWaiters = new Map<number, Array<{ resolve: (m: MemberCardDetails) => void; reject: (e: any) => void }>>()
let batchTimer: ReturnType<typeof setTimeout> | null = null; let batchTimer: ReturnType<typeof setTimeout> | null = null;
@@ -105,12 +105,13 @@ export const useMemberDirectory = defineStore('memberDirectory', () => {
try { try {
const res = await getFullMembers(ids); const res = await getFullMembers(ids);
for (const m of res) { for (const m of res) {
full[m.member_id] = m; console.log(m)
full[m.member.member_id] = m;
const waiters = fullWaiters.get(m.member_id); const waiters = fullWaiters.get(m.member.member_id);
if (waiters) { if (waiters) {
for (const w of waiters) w.resolve(m) for (const w of waiters) w.resolve(m)
fullWaiters.delete(m.member_id); fullWaiters.delete(m.member.member_id);
} }
} }