diff --git a/api/index.js b/api/index.js index 73de1b3..7382c34 100644 --- a/api/index.js +++ b/api/index.js @@ -43,6 +43,7 @@ const members = require('./routes/members'); const loaHandler = require('./routes/loa') const { status, memberStatus } = require('./routes/statuses') const authRouter = require('./routes/auth') +const { roles, memberRoles } = require('./routes/roles') app.use('/application', applicationsRouter); app.use('/ranks', ranks); @@ -51,6 +52,8 @@ app.use('/members', members); app.use('/loa', loaHandler); app.use('/status', status) app.use('/memberStatus', memberStatus) +app.use('/roles', roles) +app.use('/memberRoles', memberRoles) app.use('/', authRouter) app.get('/ping', (req, res) => { diff --git a/api/routes/ranks.js b/api/routes/ranks.js index b650aac..8a61bf1 100644 --- a/api/routes/ranks.js +++ b/api/routes/ranks.js @@ -35,6 +35,4 @@ r.get('/', async (req, res) => { }); module.exports.ranks = r; -module.exports.memberRanks = ur; - -// TODO, implement get all ranks route with SQL stirng SELECT id, name, short_name, category, sort_id FROM ranks; \ No newline at end of file +module.exports.memberRanks = ur; \ No newline at end of file diff --git a/api/routes/roles.js b/api/routes/roles.js new file mode 100644 index 0000000..d82f240 --- /dev/null +++ b/api/routes/roles.js @@ -0,0 +1,118 @@ +const express = require('express'); +const r = express.Router(); +const ur = express.Router(); + +const pool = require('../db'); + +//assign a member to a role +ur.post('/', async (req, res) => { + try { + const body = req.body; + const sql = `INSERT INTO members_roles (member_id, role_id) VALUES (?, ?);`; + + await pool.query(sql, [body.member_id, body.role_id]); + + res.sendStatus(201); + } catch (err) { + console.error('Insert failed:', err); + res.status(500).json({ error: 'Failed to add to group' }); + } +}); + +ur.delete('/', async (req, res) => { + try { + const body = req.body; + console.log(body); + + const sql = 'DELETE FROM members_roles WHERE member_id = ? AND role_id = ?' + await pool.query(sql, [body.member_id, body.role_id]) + + res.sendStatus(200); + } + catch (err) { + console.error("delete failed: ", err) + res.status(500).json({ error: 'Failed to remove from group' }); + + } +}) + +//get all roles +r.get('/', async (req, res) => { + try { + const con = await pool.getConnection(); + + // 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_status_all 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] || [] + })); + + con.release(); + res.json(result); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +//create a new role +r.post('/', async (req, res) => { + try { + const { name, color, description } = req.body; + console.log('Creating role:', { name, color, description }); + if (!name || !color) { + return res.status(400).json({ error: 'Name and color are required' }); + } + + const hexColorRegex = /^#([0-9A-Fa-f]{6})$/; + if (!hexColorRegex.test(color)) { + return res.status(400).json({ error: 'Color must be a valid hex color (#ffffff)' }); + } + + const sql = `INSERT INTO roles (name, color, description) VALUES (?, ?, ?);`; + const params = [name, color, description || null]; + + const result = await pool.query(sql, params); + + res.status(201).json({ id: result.insertId, name, color, description }); + } catch (err) { + console.error('Insert failed:', err); + res.status(500).json({ error: 'Failed to create role' }); + } +}) + +r.delete('/:id', async (req, res) => { + try { + const id = req.params.id; + + const sql = 'DELETE FROM roles WHERE id = ?'; + const res = await pool.query(sql, [id]); + res.sendStatus(200); + } catch (error) { + console.log(error); + res.sendStatus(500); + } +}) + +module.exports.roles = r; +module.exports.memberRoles = ur; \ No newline at end of file diff --git a/ui/package-lock.json b/ui/package-lock.json index 8c0a15c..da4cbea 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,6 +8,11 @@ "name": "milsimsitev4", "version": "0.0.0", "dependencies": { + "@fullcalendar/core": "^6.1.19", + "@fullcalendar/daygrid": "^6.1.19", + "@fullcalendar/interaction": "^6.1.19", + "@fullcalendar/timegrid": "^6.1.19", + "@fullcalendar/vue3": "^6.1.19", "@tailwindcss/vite": "^4.1.11", "@tanstack/vue-table": "^8.21.3", "@vee-validate/zod": "^4.15.1", @@ -982,6 +987,55 @@ } } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.19.tgz", + "integrity": "sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ==", + "license": "MIT", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.19.tgz", + "integrity": "sha512-IAAfnMICnVWPjpT4zi87i3FEw0xxSza0avqY/HedKEz+l5MTBYvCDPOWDATpzXoLut3aACsjktIyw9thvIcRYQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.19" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.19.tgz", + "integrity": "sha512-GOciy79xe8JMVp+1evAU3ytdwN/7tv35t5i1vFkifiuWcQMLC/JnLg/RA2s4sYmQwoYhTw/p4GLcP0gO5B3X5w==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.19" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.19.tgz", + "integrity": "sha512-OuzpUueyO9wB5OZ8rs7TWIoqvu4v3yEqdDxZ2VcsMldCpYJRiOe7yHWKr4ap5Tb0fs7Rjbserc/b6Nt7ol6BRg==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.19" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.19" + } + }, + "node_modules/@fullcalendar/vue3": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.19.tgz", + "integrity": "sha512-j5eUSxx0xIy3ADljo0f5B9PhjqXnCQ+7nUMPfsslc2eGVjp4F74YvY3dyd6OBbg13IvpsjowkjncGipYMQWmTA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.19", + "vue": "^3.0.11" + } + }, "node_modules/@internationalized/date": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz", @@ -3154,6 +3208,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/pretty-ms": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", diff --git a/ui/package.json b/ui/package.json index aaa88ff..9771d5d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,11 @@ "preview": "vite preview" }, "dependencies": { + "@fullcalendar/core": "^6.1.19", + "@fullcalendar/daygrid": "^6.1.19", + "@fullcalendar/interaction": "^6.1.19", + "@fullcalendar/timegrid": "^6.1.19", + "@fullcalendar/vue3": "^6.1.19", "@tailwindcss/vite": "^4.1.11", "@tanstack/vue-table": "^8.21.3", "@vee-validate/zod": "^4.15.1", diff --git a/ui/src/App.vue b/ui/src/App.vue index 224e34d..41f7571 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -51,9 +51,9 @@ function formatDate(dateStr) { - + @@ -87,6 +87,9 @@ function formatDate(dateStr) { + + + @@ -112,14 +115,14 @@ function formatDate(dateStr) { - +

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

- + diff --git a/ui/src/api/roles.ts b/ui/src/api/roles.ts new file mode 100644 index 0000000..ef669d7 --- /dev/null +++ b/ui/src/api/roles.ts @@ -0,0 +1,95 @@ +export type Role = { + id: number; + name: string; + color: string; + description: string | null; + members: any[]; +}; + +// @ts-ignore +const addr = import.meta.env.VITE_APIHOST; + +export async function getRoles(): Promise { + const res = await fetch(`${addr}/roles`) + + if (res.ok) { + return res.json() as Promise; + } else { + console.error("Something went wrong approving the application") + return []; + } +} + +export async function createRole(name: string, color: string, description: string | null): Promise { + const res = await fetch(`${addr}/roles`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + name, + color, + description + }) + }); + + if (res.ok) { + return res.json() as Promise; + } else { + console.error("Something went wrong creating the role"); + return null; + } +} + +export async function addMemberToRole(member_id: number, role_id: number): Promise { + const res = await fetch(`${addr}/memberRoles`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + member_id, + role_id + }) + }); + + if (res.ok) { + return true; + } else { + console.error("Something went wrong adding the member to the role"); + return false; + } +} + +export async function removeMemberFromRole(member_id: number, role_id: number): Promise { + const res = await fetch(`${addr}/memberRoles`, { + method: "DELETE", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + member_id, + role_id + }) + }); + + if (res.ok) { + return true; + } else { + console.error("Something went wrong removing the member from the role"); + return false; + } +} + +export async function deleteRole(role_id: number): Promise { + const res = await fetch(`${addr}/roles/${role_id}`, { + method: "DELETE" + }); + + if (res.ok) { + return true; + } else { + console.error("Something went wrong deleting the role"); + return false; + } +} \ No newline at end of file diff --git a/ui/src/pages/Calendar.vue b/ui/src/pages/Calendar.vue new file mode 100644 index 0000000..6340acd --- /dev/null +++ b/ui/src/pages/Calendar.vue @@ -0,0 +1,451 @@ + + + + + diff --git a/ui/src/pages/ManageRoles.vue b/ui/src/pages/ManageRoles.vue new file mode 100644 index 0000000..323b4e4 --- /dev/null +++ b/ui/src/pages/ManageRoles.vue @@ -0,0 +1,273 @@ + + \ No newline at end of file diff --git a/ui/src/router/index.js b/ui/src/router/index.js index 6e80ddb..ec22e5d 100644 --- a/ui/src/router/index.js +++ b/ui/src/router/index.js @@ -9,6 +9,7 @@ const router = createRouter({ { path: '/members', component: () => import('@/pages/memberList.vue') }, { path: '/loa', component: () => import('@/pages/SubmitLOA.vue') }, { path: '/transfer', component: () => import('@/pages/Transfer.vue') }, + { path: '/calendar', component: () => import('@/pages/Calendar.vue') }, { path: '/administration', children: [ @@ -31,6 +32,10 @@ const router = createRouter({ { path: 'loa', component: () => import('@/pages/ManageLOA.vue') + }, + { + path: 'roles', + component: () => import('@/pages/ManageRoles.vue') } ] }