Compare commits
43 Commits
LOA-Fixes
...
Role-Manag
| Author | SHA1 | Date | |
|---|---|---|---|
| 8903baef52 | |||
| 80786f996f | |||
| f124e41630 | |||
| a699c20f9b | |||
| e01e742c07 | |||
| 1db75ee773 | |||
| f0624a64bd | |||
| 8c04d2cf05 | |||
| f3e35f3f6a | |||
| d7b099ac75 | |||
| a6b521a89c | |||
| ad4d28b5dd | |||
| ac22e36202 | |||
| 3180097aa7 | |||
| 4476dd6d74 | |||
| 9ec6d65a4d | |||
| 65f8527cc5 | |||
| bded69345f | |||
| 6f7a0bf3b2 | |||
| 50a94ae86f | |||
| 5b61687cd9 | |||
| 1cf6b35021 | |||
| 18d08af91d | |||
| a95dbe2623 | |||
| 6f3ab2da73 | |||
| 8f397131d4 | |||
| 9139a06f4c | |||
| a7c2ed7dff | |||
| 637968552d | |||
| 9953e2765a | |||
| f2acf86dd8 | |||
| 2127a48a83 | |||
| 6cb53deee3 | |||
| b6a9b6f855 | |||
| 2f7eb37771 | |||
| 0cfc5ce12b | |||
| 044dc78122 | |||
| 2e31b9bce4 | |||
| fd6a1822f4 | |||
| d484c357fb | |||
| 0a2c6785c6 | |||
| 5f03820891 | |||
| e9cce2571a |
13
api/package-lock.json
generated
13
api/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/node": "^10.27.0",
|
"@sentry/node": "^10.27.0",
|
||||||
|
"chalk": "^5.6.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",
|
||||||
@@ -1314,6 +1315,18 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/node": "^10.27.0",
|
"@sentry/node": "^10.27.0",
|
||||||
|
"chalk": "^5.6.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",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const pool = mariadb.createPool({
|
|||||||
connectionLimit: 5,
|
connectionLimit: 5,
|
||||||
connectTimeout: 10000, // give it more breathing room
|
connectTimeout: 10000, // give it more breathing room
|
||||||
acquireTimeout: 15000,
|
acquireTimeout: 15000,
|
||||||
database: 'ranger_unit_tracker',
|
database: process.env.DB_DATABASE,
|
||||||
ssl: false,
|
ssl: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,27 @@ 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()
|
||||||
app.use(morgan('dev', {
|
import chalk from 'chalk';
|
||||||
skip: (req) => {
|
app.use(morgan((tokens: morgan.TokenIndexer, req: express.Request, res: express.Response) => {
|
||||||
return req.path === '/members/me';
|
const status = Number(tokens.status(req, res));
|
||||||
|
|
||||||
|
// Colorize status code
|
||||||
|
const statusColor = status >= 500 ? chalk.red
|
||||||
|
: status >= 400 ? chalk.yellow
|
||||||
|
: status >= 300 ? chalk.cyan
|
||||||
|
: chalk.green;
|
||||||
|
|
||||||
|
return [
|
||||||
|
chalk.gray(`[${new Date().toISOString()}]`),
|
||||||
|
chalk.blue.bold(tokens.method(req, res)),
|
||||||
|
tokens.url(req, res),
|
||||||
|
statusColor(status),
|
||||||
|
chalk.magenta(tokens['response-time'](req, res) + ' ms'),
|
||||||
|
chalk.yellow(`- User: ${req.user?.name ? `${req.user.name} (${req.user.id})` : 'Unauthenticated'}`),
|
||||||
|
].join(' ');
|
||||||
|
}, {
|
||||||
|
skip: (req: express.Request) => {
|
||||||
|
return req.originalUrl === '/members/me';
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -64,6 +82,7 @@ import { authRouter } from './routes/auth';
|
|||||||
import { roles, memberRoles } from './routes/roles';
|
import { roles, memberRoles } from './routes/roles';
|
||||||
import { courseRouter, eventRouter } from './routes/course';
|
import { courseRouter, eventRouter } from './routes/course';
|
||||||
import { calendarRouter } from './routes/calendar';
|
import { calendarRouter } from './routes/calendar';
|
||||||
|
import { docsRouter } from './routes/docs';
|
||||||
|
|
||||||
app.use('/application', applicationRouter);
|
app.use('/application', applicationRouter);
|
||||||
app.use('/ranks', ranks);
|
app.use('/ranks', ranks);
|
||||||
@@ -77,6 +96,7 @@ app.use('/memberRoles', memberRoles)
|
|||||||
app.use('/course', courseRouter)
|
app.use('/course', courseRouter)
|
||||||
app.use('/courseEvent', eventRouter)
|
app.use('/courseEvent', eventRouter)
|
||||||
app.use('/calendar', calendarRouter)
|
app.use('/calendar', calendarRouter)
|
||||||
|
app.use('/docs', docsRouter)
|
||||||
app.use('/', authRouter)
|
app.use('/', authRouter)
|
||||||
|
|
||||||
app.get('/ping', (req, res) => {
|
app.get('/ping', (req, res) => {
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ router.get('/me/:id', [requireLogin], async (req: Request, res: Response) => {
|
|||||||
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) {
|
if (application.member_id != member) {
|
||||||
return res.sendStatus(403);
|
return res.sendStatus(403);
|
||||||
}
|
}
|
||||||
@@ -191,8 +190,6 @@ router.post('/:id/comment', [requireLogin], async (req: Request, res: Response)
|
|||||||
const data = req.body.message;
|
const data = req.body.message;
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
|
|
||||||
console.log(user)
|
|
||||||
|
|
||||||
const sql = `INSERT INTO application_comments(
|
const sql = `INSERT INTO application_comments(
|
||||||
application_id,
|
application_id,
|
||||||
poster_id,
|
poster_id,
|
||||||
@@ -205,7 +202,6 @@ VALUES(?, ?, ?);`
|
|||||||
var conn = await pool.getConnection();
|
var conn = await pool.getConnection();
|
||||||
|
|
||||||
const result = await conn.query(sql, [appID, user.id, data])
|
const result = await conn.query(sql, [appID, user.id, data])
|
||||||
console.log(result)
|
|
||||||
if (result.affectedRows !== 1) {
|
if (result.affectedRows !== 1) {
|
||||||
conn.release();
|
conn.release();
|
||||||
throw new Error("Insert Failure")
|
throw new Error("Insert Failure")
|
||||||
@@ -237,8 +233,6 @@ router.post('/:id/adminComment', [requireLogin, requireRole("Recruiter")], async
|
|||||||
const data = req.body.message;
|
const data = req.body.message;
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
|
|
||||||
console.log(user)
|
|
||||||
|
|
||||||
const sql = `INSERT INTO application_comments(
|
const sql = `INSERT INTO application_comments(
|
||||||
application_id,
|
application_id,
|
||||||
poster_id,
|
poster_id,
|
||||||
@@ -251,7 +245,6 @@ VALUES(?, ?, ?, 1);`
|
|||||||
var conn = await pool.getConnection();
|
var conn = await pool.getConnection();
|
||||||
|
|
||||||
const result = await conn.query(sql, [appID, user.id, data])
|
const result = await conn.query(sql, [appID, user.id, data])
|
||||||
console.log(result)
|
|
||||||
if (result.affectedRows !== 1) {
|
if (result.affectedRows !== 1) {
|
||||||
conn.release();
|
conn.release();
|
||||||
throw new Error("Insert Failure")
|
throw new Error("Insert Failure")
|
||||||
|
|||||||
@@ -115,11 +115,24 @@ router.get('/callback', (req, res, next) => {
|
|||||||
router.get('/logout', [requireLogin], function (req, res, next) {
|
router.get('/logout', [requireLogin], function (req, res, next) {
|
||||||
req.logout(function (err) {
|
req.logout(function (err) {
|
||||||
if (err) { return next(err); }
|
if (err) { return next(err); }
|
||||||
var params = {
|
|
||||||
client_id: process.env.AUTH_CLIENT_ID,
|
req.session.destroy((err) => {
|
||||||
returnTo: process.env.CLIENT_URL
|
if (err) { return next(err); }
|
||||||
};
|
|
||||||
res.redirect(process.env.AUTH_END_SESSION_URI + '?' + querystring.stringify(params));
|
res.clearCookie('connect.sid', {
|
||||||
|
path: '/',
|
||||||
|
domain: process.env.CLIENT_DOMAIN,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax'
|
||||||
|
});
|
||||||
|
|
||||||
|
var params = {
|
||||||
|
client_id: process.env.AUTH_CLIENT_ID,
|
||||||
|
returnTo: process.env.CLIENT_URL
|
||||||
|
};
|
||||||
|
res.redirect(process.env.AUTH_END_SESSION_URI + '?' + querystring.stringify(params));
|
||||||
|
|
||||||
|
})
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ r.put('/', [requireLogin, requireMemberState(MemberState.Member)], async (req: R
|
|||||||
let event: CalendarEvent = req.body;
|
let event: CalendarEvent = req.body;
|
||||||
event.start = new Date(event.start);
|
event.start = new Date(event.start);
|
||||||
event.end = new Date(event.end);
|
event.end = new Date(event.end);
|
||||||
console.log(event);
|
|
||||||
updateEvent(event);
|
updateEvent(event);
|
||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ er.get('/attendees/:id', async (req: Request, res: Response) => {
|
|||||||
er.post('/', async (req: Request, res: Response) => {
|
er.post('/', async (req: Request, res: Response) => {
|
||||||
const posterID: number = req.user.id;
|
const posterID: number = req.user.id;
|
||||||
try {
|
try {
|
||||||
console.log();
|
|
||||||
let data: CourseEventDetails = req.body;
|
let data: CourseEventDetails = req.body;
|
||||||
data.created_by = posterID;
|
data.created_by = posterID;
|
||||||
data.event_date = new Date(data.event_date);
|
data.event_date = new Date(data.event_date);
|
||||||
|
|||||||
24
api/src/routes/docs.ts
Normal file
24
api/src/routes/docs.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { requireLogin } from '../middleware/auth';
|
||||||
|
|
||||||
|
router.get('/welcome', [requireLogin], async (req: Request, res: Response) => {
|
||||||
|
const output = await fetch(`${process.env.DOC_HOST}/api/pages/717`, {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export const docsRouter = router;
|
||||||
@@ -30,9 +30,6 @@ router.post("/admin", [requireRole("17th Administrator")], async (req: Request,
|
|||||||
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();
|
||||||
|
|
||||||
console.log(LOARequest);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createNewLOA(LOARequest);
|
await createNewLOA(LOARequest);
|
||||||
res.sendStatus(201);
|
res.sendStatus(201);
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ router.get('/me', [requireLogin], async (req, res) => {
|
|||||||
router.get('/settings', [requireLogin], async (req: Request, res: Response) => {
|
router.get('/settings', [requireLogin], async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
let user = req.user.id;
|
let user = req.user.id;
|
||||||
console.log(user);
|
|
||||||
let output = await getMemberSettings(user);
|
let output = await getMemberSettings(user);
|
||||||
res.status(200).json(output);
|
res.status(200).json(output);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -66,7 +65,6 @@ router.put('/settings', [requireLogin], async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
let user = req.user.id;
|
let user = req.user.id;
|
||||||
let settings: memberSettings = req.body;
|
let settings: memberSettings = req.body;
|
||||||
console.log(settings)
|
|
||||||
await setUserSettings(user, settings);
|
await setUserSettings(user, settings);
|
||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
@@ -28,7 +35,6 @@ ur.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administ
|
|||||||
ur.delete('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => {
|
ur.delete('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body;
|
const body = req.body;
|
||||||
console.log(body);
|
|
||||||
|
|
||||||
const sql = 'DELETE FROM members_roles WHERE member_id = ? AND role_id = ?'
|
const sql = 'DELETE FROM members_roles WHERE member_id = ? AND role_id = ?'
|
||||||
await pool.query(sql, [body.member_id, body.role_id])
|
await pool.query(sql, [body.member_id, body.role_id])
|
||||||
@@ -45,48 +51,41 @@ 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;
|
||||||
console.log('Creating role:', { name, color, description });
|
|
||||||
if (!name || !color) {
|
if (!name || !color) {
|
||||||
return res.status(400).json({ error: 'Name and color are required' });
|
return res.status(400).json({ error: 'Name and color are required' });
|
||||||
}
|
}
|
||||||
@@ -105,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;
|
||||||
|
|
||||||
@@ -113,7 +112,7 @@ r.delete('/:id', [requireMemberState(MemberState.Member), requireRole("17th Admi
|
|||||||
const res = await pool.query(sql, [id]);
|
const res = await pool.query(sql, [id]);
|
||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.error(error);
|
||||||
res.sendStatus(500);
|
res.sendStatus(500);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export async function approveApplication(id: number, approver: number) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await pool.execute(sql, [approver, id]);
|
const result = await pool.execute(sql, [approver, id]);
|
||||||
console.log(result);
|
|
||||||
if (result.affectedRows == 1) {
|
if (result.affectedRows == 1) {
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
@@ -91,7 +90,6 @@ export async function denyApplication(id: number, approver: number) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await pool.execute(sql, [approver, id]);
|
const result = await pool.execute(sql, [approver, id]);
|
||||||
console.log(result);
|
|
||||||
if (result.affectedRows == 1) {
|
if (result.affectedRows == 1) {
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -126,6 +126,5 @@ export async function getEventAttendance(eventID: number): Promise<CalendarSignu
|
|||||||
|
|
||||||
const sql = "CALL `sp_GetCalendarEventSignups`(?)"
|
const sql = "CALL `sp_GetCalendarEventSignups`(?)"
|
||||||
const res = await pool.query(sql, [eventID]);
|
const res = await pool.query(sql, [eventID]);
|
||||||
console.log(res[0]);
|
|
||||||
return res[0];
|
return res[0];
|
||||||
}
|
}
|
||||||
@@ -89,13 +89,11 @@ export async function closeLOA(id: number, closer: number) {
|
|||||||
ended_at = NOW()
|
ended_at = NOW()
|
||||||
WHERE leave_of_absences.id = ?`;
|
WHERE leave_of_absences.id = ?`;
|
||||||
let out = await pool.query(sql, [closer, id]);
|
let out = await pool.query(sql, [closer, id]);
|
||||||
console.log(out);
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLOAbyID(id: number): Promise<LOARequest> {
|
export async function getLOAbyID(id: number): Promise<LOARequest> {
|
||||||
let res = await pool.query(`SELECT * FROM leave_of_absences WHERE id = ?`, [id]);
|
let res = await pool.query(`SELECT * FROM leave_of_absences WHERE id = ?`, [id]);
|
||||||
console.log(res);
|
|
||||||
if (res.length != 1)
|
if (res.length != 1)
|
||||||
throw new Error(`LOA with id ${id} not found`);
|
throw new Error(`LOA with id ${id} not found`);
|
||||||
return res[0];
|
return res[0];
|
||||||
|
|||||||
@@ -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 = ?`;
|
||||||
@@ -34,7 +35,6 @@ export async function setUserSettings(id: number, settings: memberSettings) {
|
|||||||
displayName = ?
|
displayName = ?
|
||||||
WHERE id = ?;`;
|
WHERE id = ?;`;
|
||||||
let result = await pool.query(sql, [settings.displayName, id])
|
let result = await pool.query(sql, [settings.displayName, id])
|
||||||
console.log(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMembersLite(ids: number[]): Promise<MemberLight[]> {
|
export async function getMembersLite(ids: number[]): Promise<MemberLight[]> {
|
||||||
@@ -61,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> {
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
@@ -21,7 +21,37 @@ export async function getUserRoles(userID: number): Promise<Role[]> {
|
|||||||
const sql = `SELECT r.id, r.name
|
const sql = `SELECT r.id, r.name
|
||||||
FROM members_roles mr
|
FROM members_roles mr
|
||||||
INNER JOIN roles r ON mr.role_id = r.id
|
INNER JOIN roles r ON mr.role_id = r.id
|
||||||
WHERE mr.member_id = 190;`;
|
WHERE mr.member_id = ?;`;
|
||||||
|
|
||||||
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[]
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
export function toDateTime(date: Date): string {
|
export function toDateTime(date: Date): string {
|
||||||
console.log(date);
|
|
||||||
if (typeof date === 'string') {
|
if (typeof date === 'string') {
|
||||||
date = new Date(date);
|
date = new Date(date);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
ui/public/bg.jpg
Normal file
BIN
ui/public/bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 543 KiB |
@@ -22,7 +22,10 @@ const environment = import.meta.env.VITE_ENVIRONMENT;
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col min-h-screen">
|
<div class="flex flex-col min-h-screen" style="background-image: linear-gradient(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.25)), url('/bg.jpg');
|
||||||
|
background-size: contain;
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-position: center;">
|
||||||
<div class="sticky top-0 bg-background z-50">
|
<div class="sticky top-0 bg-background z-50">
|
||||||
<Navbar class="flex"></Navbar>
|
<Navbar class="flex"></Navbar>
|
||||||
<Alert v-if="environment == 'dev'" class="m-2 mx-auto w-5xl" variant="info">
|
<Alert v-if="environment == 'dev'" class="m-2 mx-auto w-5xl" variant="info">
|
||||||
@@ -32,8 +35,10 @@ const environment = import.meta.env.VITE_ENVIRONMENT;
|
|||||||
</Alert>
|
</Alert>
|
||||||
<Alert v-if="userStore.user?.LOAs?.[0]" class="m-2 mx-auto w-5xl" variant="info">
|
<Alert v-if="userStore.user?.LOAs?.[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?.LOAs?.[0].extended_till || userStore.user?.LOAs?.[0].end_date) }}</strong></p>
|
<p>You are on LOA until <strong>{{ formatDate(userStore.user?.LOAs?.[0].extended_till ||
|
||||||
<Button variant="secondary" @click="async () => { await cancelLOA(userStore.user.LOAs?.[0].id); userStore.loadUser(); }">End
|
userStore.user?.LOAs?.[0].end_date) }}</strong></p>
|
||||||
|
<Button variant="secondary"
|
||||||
|
@click="async () => { await cancelLOA(userStore.user.LOAs?.[0].id); userStore.loadUser(); }">End
|
||||||
LOA</Button>
|
LOA</Button>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
18
ui/src/api/docs.ts
Normal file
18
ui/src/api/docs.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
const addr = import.meta.env.VITE_APIHOST;
|
||||||
|
|
||||||
|
export async function getWelcomeMessage(): Promise<string> {
|
||||||
|
const res = await fetch(`${addr}/docs/welcome`, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const out = res.json();
|
||||||
|
if (!out) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
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;
|
||||||
|
|
||||||
export async function getRoles(): Promise<Role[]> {
|
export async function getRoles(): Promise<Role[]> {
|
||||||
const res = await fetch(`${addr}/roles`)
|
const res = await fetch(`${addr}/roles`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
return res.json() as Promise<Role[]>;
|
return res.json() as Promise<Role[]>;
|
||||||
@@ -20,17 +17,42 @@ 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",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
description
|
description
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -47,6 +69,7 @@ export async function addMemberToRole(member_id: number, role_id: number): Promi
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
member_id,
|
member_id,
|
||||||
role_id
|
role_id
|
||||||
@@ -64,6 +87,7 @@ export async function addMemberToRole(member_id: number, role_id: number): Promi
|
|||||||
export async function removeMemberFromRole(member_id: number, role_id: number): Promise<boolean> {
|
export async function removeMemberFromRole(member_id: number, role_id: number): Promise<boolean> {
|
||||||
const res = await fetch(`${addr}/memberRoles`, {
|
const res = await fetch(`${addr}/memberRoles`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
@@ -83,7 +107,8 @@ export async function removeMemberFromRole(member_id: number, role_id: number):
|
|||||||
|
|
||||||
export async function deleteRole(role_id: number): Promise<boolean> {
|
export async function deleteRole(role_id: number): Promise<boolean> {
|
||||||
const res = await fetch(`${addr}/roles/${role_id}`, {
|
const res = await fetch(`${addr}/roles/${role_id}`, {
|
||||||
method: "DELETE"
|
method: "DELETE",
|
||||||
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(0.2046 0 0);
|
--background: oklch(19.125% 0.00002 271.152);
|
||||||
--foreground: oklch(0.9219 0 0);
|
--foreground: oklch(0.9219 0 0);
|
||||||
--card: oklch(23.075% 0.00003 271.152);
|
--card: oklch(23.075% 0.00003 271.152);
|
||||||
--card-foreground: oklch(0.9219 0 0);
|
--card-foreground: oklch(0.9219 0 0);
|
||||||
|
|||||||
@@ -85,11 +85,11 @@ function blurAfter() {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
|
|
||||||
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
|
<!-- <NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
|
||||||
<RouterLink to="/transfer" @click="blurAfter">
|
<RouterLink to="/transfer" @click="blurAfter">
|
||||||
Transfer Request
|
Transfer Request
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink> -->
|
||||||
|
|
||||||
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
|
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
|
||||||
<RouterLink to="/trainingReport" @click="blurAfter">
|
<RouterLink to="/trainingReport" @click="blurAfter">
|
||||||
@@ -107,13 +107,13 @@ function blurAfter() {
|
|||||||
<NavigationMenuContent
|
<NavigationMenuContent
|
||||||
class="grid gap-1 p-2 text-left [&_a]:w-full [&_a]:block [&_a]:whitespace-nowrap *:bg-transparent">
|
class="grid gap-1 p-2 text-left [&_a]:w-full [&_a]:block [&_a]:whitespace-nowrap *:bg-transparent">
|
||||||
|
|
||||||
<NavigationMenuLink
|
<!-- <NavigationMenuLink
|
||||||
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])"
|
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])"
|
||||||
as-child :class="navigationMenuTriggerStyle()">
|
as-child :class="navigationMenuTriggerStyle()">
|
||||||
<RouterLink to="/administration/rankChange" @click="blurAfter">
|
<RouterLink to="/administration/rankChange" @click="blurAfter">
|
||||||
Promotions
|
Promotions
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink> -->
|
||||||
|
|
||||||
<NavigationMenuLink
|
<NavigationMenuLink
|
||||||
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])"
|
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])"
|
||||||
@@ -123,13 +123,13 @@ function blurAfter() {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
|
|
||||||
<NavigationMenuLink
|
<!-- <NavigationMenuLink
|
||||||
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])"
|
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])"
|
||||||
as-child :class="navigationMenuTriggerStyle()">
|
as-child :class="navigationMenuTriggerStyle()">
|
||||||
<RouterLink to="/administration/transfer" @click="blurAfter">
|
<RouterLink to="/administration/transfer" @click="blurAfter">
|
||||||
Transfer Requests
|
Transfer Requests
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink> -->
|
||||||
|
|
||||||
<NavigationMenuLink v-if="auth.hasRole('Recruiter')" as-child
|
<NavigationMenuLink v-if="auth.hasRole('Recruiter')" as-child
|
||||||
:class="navigationMenuTriggerStyle()">
|
:class="navigationMenuTriggerStyle()">
|
||||||
@@ -147,11 +147,11 @@ function blurAfter() {
|
|||||||
</NavigationMenuContent>
|
</NavigationMenuContent>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
|
||||||
<NavigationMenuItem as-child :class="navigationMenuTriggerStyle()">
|
<!-- <NavigationMenuItem as-child :class="navigationMenuTriggerStyle()">
|
||||||
<RouterLink to="/members" @click="blurAfter">
|
<RouterLink to="/members" @click="blurAfter">
|
||||||
Members (debug)
|
Members (debug)
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem> -->
|
||||||
|
|
||||||
</NavigationMenuList>
|
</NavigationMenuList>
|
||||||
</NavigationMenu>
|
</NavigationMenu>
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ async function setAttendance(state: CalendarAttendance) {
|
|||||||
|
|
||||||
const canEditEvent = computed(() => {
|
const canEditEvent = computed(() => {
|
||||||
if (!userStore.isLoggedIn) return false;
|
if (!userStore.isLoggedIn) return false;
|
||||||
|
if (userStore.state !== 'member') return false;
|
||||||
if (userStore.user.member.member_id == activeEvent.value.creator_id)
|
if (userStore.user.member.member_id == activeEvent.value.creator_id)
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -196,7 +197,7 @@ defineExpose({ forceReload })
|
|||||||
<DropdownMenuItem v-if="activeEvent.cancelled" @click="setCancel(false)">
|
<DropdownMenuItem v-if="activeEvent.cancelled" @click="setCancel(false)">
|
||||||
Un-Cancel
|
Un-Cancel
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem v-else @click="setCancel(true)">
|
<DropdownMenuItem v-else @click="setCancel(true)" class="text-destructive">
|
||||||
Cancel
|
Cancel
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -215,7 +216,7 @@ defineExpose({ forceReload })
|
|||||||
<CircleAlert></CircleAlert> This event has been cancelled
|
<CircleAlert></CircleAlert> This event has been cancelled
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section v-if="isPast && userStore.isLoggedIn" class="w-full">
|
<section v-if="isPast && userStore.state === 'member'" class="w-full">
|
||||||
<ButtonGroup class="flex w-full">
|
<ButtonGroup class="flex w-full">
|
||||||
<Button variant="outline"
|
<Button variant="outline"
|
||||||
:class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''"
|
:class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''"
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ const { handleSubmit, values, resetForm } = useForm({
|
|||||||
const formSubmitted = ref(false);
|
const formSubmitted = ref(false);
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (values) => {
|
const onSubmit = handleSubmit(async (values) => {
|
||||||
console.log(values);
|
|
||||||
const out: LOARequest = {
|
const out: LOARequest = {
|
||||||
member_id: values.member_id,
|
member_id: values.member_id,
|
||||||
start_date: values.start_date,
|
start_date: values.start_date,
|
||||||
@@ -122,7 +121,7 @@ const minEndDate = computed(() => {
|
|||||||
if (values.start_date) {
|
if (values.start_date) {
|
||||||
return new CalendarDate(values.start_date.getFullYear(), values.start_date.getMonth() + 1, values.start_date.getDate())
|
return new CalendarDate(values.start_date.getFullYear(), values.start_date.getMonth() + 1, values.start_date.getDate())
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return today(getLocalTimeZone());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -135,6 +134,17 @@ const maxEndDate = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const minStartDate = computed(() => {
|
||||||
|
if (values.type && values.end_date) {
|
||||||
|
let endDateObj = new Date(values.end_date.getTime() - values.type.max_length_days * 24 * 60 * 60 * 1000);
|
||||||
|
let td = today(getLocalTimeZone());
|
||||||
|
let start = new CalendarDate(endDateObj.getFullYear(), endDateObj.getMonth() + 1, endDateObj.getDate())
|
||||||
|
return td.compare(start) > 0 ? td : start;
|
||||||
|
} else {
|
||||||
|
return today(getLocalTimeZone());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const memberFilter = ref('');
|
const memberFilter = ref('');
|
||||||
|
|
||||||
const filteredMembers = computed(() => {
|
const filteredMembers = computed(() => {
|
||||||
@@ -227,21 +237,31 @@ const filteredMembers = computed(() => {
|
|||||||
<FieldContent>
|
<FieldContent>
|
||||||
<FieldLabel>Start Date</FieldLabel>
|
<FieldLabel>Start Date</FieldLabel>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger as-child>
|
<div class="relative inline-flex items-center group">
|
||||||
<Button variant="outline" :class="cn(
|
<PopoverTrigger as-child>
|
||||||
'w-full justify-start text-left font-normal',
|
<Button :disabled="!values.type" variant="outline" :class="cn(
|
||||||
!field.value && 'text-muted-foreground',
|
'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" }}
|
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||||
</Button>
|
{{ field.value ? df.format(field.value) : "Pick a date" }}
|
||||||
</PopoverTrigger>
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<!-- Tooltip bubble -->
|
||||||
|
<div v-if="!values?.type" 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">
|
||||||
|
Select an LOA type first
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<PopoverContent class="w-auto p-0">
|
<PopoverContent class="w-auto p-0">
|
||||||
<Calendar
|
<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()))"
|
@update:model-value="(val: CalendarDate) => field.onChange(val.toDate(getLocalTimeZone()))"
|
||||||
layout="month-and-year" :min-value="today(getLocalTimeZone())" />
|
layout="month-and-year"
|
||||||
|
:min-value="minStartDate || today(getLocalTimeZone())" />
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<div class="h-4">
|
<div class="h-4">
|
||||||
@@ -255,18 +275,28 @@ const filteredMembers = computed(() => {
|
|||||||
<FieldContent>
|
<FieldContent>
|
||||||
<FieldLabel>End Date</FieldLabel>
|
<FieldLabel>End Date</FieldLabel>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger as-child>
|
<div class="relative inline-flex items-center group">
|
||||||
<Button variant="outline" :class="cn(
|
<PopoverTrigger as-child>
|
||||||
'w-full justify-start text-left font-normal',
|
<Button :disabled="!values.type" variant="outline" :class="cn(
|
||||||
!field.value && 'text-muted-foreground',
|
'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" }}
|
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||||
</Button>
|
{{ field.value ? df.format(field.value) : "Pick a date" }}
|
||||||
</PopoverTrigger>
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<!-- Tooltip bubble -->
|
||||||
|
<div v-if="!values?.type" 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">
|
||||||
|
Select an LOA type first
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<PopoverContent class="w-auto p-0">
|
<PopoverContent class="w-auto p-0">
|
||||||
<Calendar
|
<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()))"
|
@update:model-value="(val: CalendarDate) => field.onChange(val.toDate(getLocalTimeZone()))"
|
||||||
:default-placeholder="defaultPlaceholder" :min-value="minEndDate"
|
:default-placeholder="defaultPlaceholder" :min-value="minEndDate"
|
||||||
:max-value="maxEndDate" layout="month-and-year">
|
:max-value="maxEndDate" layout="month-and-year">
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
103
ui/src/components/roles/addMember.vue
Normal file
103
ui/src/components/roles/addMember.vue
Normal 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>
|
||||||
142
ui/src/components/roles/roleView.vue
Normal file
142
ui/src/components/roles/roleView.vue
Normal 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>
|
||||||
@@ -8,7 +8,7 @@ export const buttonVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/70",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -49,15 +49,8 @@ const dialogRef = ref<any>(null)
|
|||||||
// NEW: handle day/time slot clicks to start creating an event
|
// NEW: handle day/time slot clicks to start creating an event
|
||||||
function onDateClick(arg: { dateStr: string }) {
|
function onDateClick(arg: { dateStr: string }) {
|
||||||
if (!userStore.isLoggedIn) return;
|
if (!userStore.isLoggedIn) return;
|
||||||
|
if (userStore.state !== 'member') return;
|
||||||
dialogRef.value?.openDialog(arg.dateStr);
|
dialogRef.value?.openDialog(arg.dateStr);
|
||||||
// For now, just open the panel with a draft payload.
|
|
||||||
// activeEvent.value = {
|
|
||||||
// id: '__draft__',
|
|
||||||
// title: 'New event',
|
|
||||||
// start: arg.dateStr,
|
|
||||||
// extendedProps: { draft: true }
|
|
||||||
// }
|
|
||||||
// panelOpen.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const calendarOptions = ref({
|
const calendarOptions = ref({
|
||||||
@@ -203,7 +196,7 @@ onMounted(() => {
|
|||||||
@click="goToday">
|
@click="goToday">
|
||||||
Today
|
Today
|
||||||
</button>
|
</button>
|
||||||
<button v-if="userStore.isLoggedIn"
|
<button v-if="userStore.isLoggedIn && userStore.state === 'member'"
|
||||||
class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
|
class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
|
||||||
@click="onCreateEvent">
|
@click="onCreateEvent">
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getWelcomeMessage } from '@/api/docs';
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -8,23 +10,126 @@ const router = useRouter()
|
|||||||
const user = useUserStore();
|
const user = useUserStore();
|
||||||
|
|
||||||
function goToApplication() {
|
function goToApplication() {
|
||||||
router.push('/apply') // change to your form route
|
router.push('/join') // change to your form route
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (user.state == 'member') {
|
||||||
|
let policy = await getWelcomeMessage() as any;
|
||||||
|
welcomeRef.value.innerHTML = policy;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const welcomeRef = ref<HTMLElement>(null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="user.state == 'guest'" class="flex flex-col items-center justify-center">
|
<div v-if="user.state == 'member'" class="mt-10">
|
||||||
<h1 class="text-4xl font-bold mb-4">Welcome to the 17th</h1>
|
<div ref="welcomeRef" class="bookstack-container">
|
||||||
<p class="text-neutral-400 mb-8 max-w-md">
|
<!-- bookstack -->
|
||||||
To join our unit, please fill out an application to continue.
|
</div>
|
||||||
</p>
|
|
||||||
<Button @click="goToApplication" class="px-6 py-3 text-lg">
|
|
||||||
Begin Application
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else class="text-foreground px-6 py-12 selection:bg-primary/10">
|
||||||
HOMEPAGEEEEEEEEEEEEEEEEEEE
|
<div class="max-w-5xl mx-auto space-y-8">
|
||||||
|
|
||||||
|
<header class="space-y-4">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-semibold tracking-tight">17th Ranger Battalion</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-px bg-border w-full"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
||||||
|
|
||||||
|
<div class="lg:col-span-7 space-y-6">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h2 class="text-sm font-medium uppercase tracking-wider text-primary">Unit Philosophy</h2>
|
||||||
|
<p class="text-lg leading-relaxed font-normal">
|
||||||
|
The 17th RBN emphasizes high-skill gameplay through real-world tactics, stripped of
|
||||||
|
traditional military formalities. We prioritize effective coordination over enforced
|
||||||
|
etiquette.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
Our "Real Life First" mindset ensures participation remains a hobby, not a second job. With
|
||||||
|
a consistent roster of 40–50 members for Saturday operations, we focus on effective
|
||||||
|
coordination and mission success without the requirement of "Yes sir, no sir" protocols.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-6 pt-4">
|
||||||
|
<Button size="lg" @click="goToApplication" class="font-medium">
|
||||||
|
Begin Application
|
||||||
|
</Button>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs uppercase tracking-tighter text-muted-foreground">Age
|
||||||
|
Requirement</span>
|
||||||
|
<span class="text-sm font-medium">18+</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-5 space-y-8">
|
||||||
|
<section class="space-y-3">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Operational Schedule</h3>
|
||||||
|
<div class="rounded-lg border bg-card p-4 shadow-sm">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm font-medium">Main Operation</span>
|
||||||
|
<span class="text-sm font-mono text-primary">Sat 19:00 CST</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
Force Structure
|
||||||
|
</h3>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li class="flex items-start gap-4">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-primary mt-2 shrink-0"></span>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-medium leading-none">Alpha Company</p>
|
||||||
|
<p class="text-[13px] text-muted-foreground leading-relaxed">
|
||||||
|
Rifleman, Medic, CLS, Anti-Tank, RTO, Leadership
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="flex items-start gap-4">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-primary mt-2 shrink-0"></span>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-medium leading-none">Echo Company</p>
|
||||||
|
<p class="text-[13px] text-muted-foreground leading-relaxed">
|
||||||
|
Logistics, CAS Pilot, Armor, Artillery, JTAC, Forward Observer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="text-[12px] text-muted-foreground/70 border-t pt-2 border-border">
|
||||||
|
Roles are fluid; specialization or weekly rotation is supported.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div class="relative rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div class="aspect-video">
|
||||||
|
<iframe class="w-full h-full"
|
||||||
|
src="https://www.youtube.com/embed/61L397HwmrU?si=oY9qf6vFv6hXo6Fk&controls=1&mute=1&start=102&end=152"
|
||||||
|
title="YouTube video player" frameborder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
allowfullscreen>
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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,7 +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, Member } from '@/api/member';
|
import { getAllLightMembers, getMembers } from '@/api/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)
|
||||||
@@ -42,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
|
||||||
@@ -116,139 +113,40 @@ async function handleCreateGroup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddMember() {
|
|
||||||
//guard
|
|
||||||
if (memberToAdd.value == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
addMemberToRole(memberToAdd.value.member_id, activeRole.value.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemoveMember(memberId: number) {
|
|
||||||
removeMemberFromRole(memberId, activeRole.value.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useMemberDirectory } from "@/stores/memberDirectory";
|
|||||||
import { useUserStore } from "@/stores/user";
|
import { useUserStore } from "@/stores/user";
|
||||||
|
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const saveSuccess = ref(false);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const showLoading = ref(false);
|
const showLoading = ref(false);
|
||||||
const form = ref<memberSettings>();
|
const form = ref<memberSettings>();
|
||||||
@@ -20,13 +21,14 @@ const userStore = useUserStore()
|
|||||||
|
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
|
saveSuccess.value = false;
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
// Replace with your API save call
|
// Replace with your API save call
|
||||||
setMemberSettings(form.value);
|
setMemberSettings(form.value);
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
console.log(userStore.user.id)
|
memberDictionary.invalidateMember(userStore.user.member.member_id)
|
||||||
memberDictionary.invalidateMember(userStore.user.id)
|
saveSuccess.value = true;
|
||||||
}, 800);
|
}, 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +77,8 @@ onMounted(async () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<CardFooter class="flex justify-end">
|
<CardFooter class="flex justify-end gap-5 items-center">
|
||||||
|
<p v-if="saveSuccess" class="text-green-500">Profile saved</p>
|
||||||
<Button @click="saveSettings" :disabled="saving">
|
<Button @click="saveSettings" :disabled="saving">
|
||||||
{{ saving ? "Saving..." : "Save Changes" }}
|
{{ saving ? "Saving..." : "Save Changes" }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const router = createRouter({
|
|||||||
{ 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: '/profile', component: () => import('@/pages/MyProfile.vue'), meta: { requiresAuth: true } },
|
||||||
|
|
||||||
|
|
||||||
{ path: '/calendar', component: () => import('@/pages/Calendar.vue') },
|
{ path: '/calendar', component: () => import('@/pages/Calendar.vue') },
|
||||||
@@ -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') }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user