Compare commits
8 Commits
promotions
...
Role-Manag
| Author | SHA1 | Date | |
|---|---|---|---|
| 8903baef52 | |||
| 80786f996f | |||
| f124e41630 | |||
| a699c20f9b | |||
| e01e742c07 | |||
| 1db75ee773 | |||
| f0624a64bd | |||
| 8c04d2cf05 |
@@ -5,7 +5,8 @@ const ur = express.Router();
|
||||
import { MemberState } from '@app/shared/types/member';
|
||||
import pool from '../db';
|
||||
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)
|
||||
ur.use(requireLogin)
|
||||
@@ -15,10 +16,16 @@ ur.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administ
|
||||
try {
|
||||
const body = req.body;
|
||||
|
||||
assignUserGroup(body.member_id, body.role_id);
|
||||
await assignUserGroup(body.member_id, body.role_id);
|
||||
|
||||
res.sendStatus(201);
|
||||
} catch (err) {
|
||||
if (err?.code === 'ER_DUP_ENTRY') {
|
||||
return res.status(400).json({
|
||||
error: 'Member already has this role',
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Insert failed:', err);
|
||||
res.status(500).json({ error: 'Failed to add to group' });
|
||||
}
|
||||
@@ -44,45 +51,39 @@ ur.delete('/', [requireMemberState(MemberState.Member), requireRole("17th Admini
|
||||
//get all roles
|
||||
r.get('/', [requireMemberState(MemberState.Member)], async (req, res) => {
|
||||
try {
|
||||
var 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_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);
|
||||
const roles = await getAllRoles();
|
||||
res.status(200).json(roles);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
} finally {
|
||||
con.release();
|
||||
res.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
r.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => {
|
||||
r.post('/', [requireMemberState(MemberState.Member), requireRole("dev")], async (req, res) => {
|
||||
try {
|
||||
const { name, color, description } = req.body;
|
||||
if (!name || !color) {
|
||||
@@ -103,7 +104,7 @@ r.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administr
|
||||
}
|
||||
})
|
||||
|
||||
r.delete('/:id', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => {
|
||||
r.delete('/:id', [requireMemberState(MemberState.Member), requireRole("dev")], async (req, res) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Role } from "@app/shared/types/roles";
|
||||
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> {
|
||||
const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id = ?`;
|
||||
@@ -60,10 +61,50 @@ export async function getAllMembersLite(): Promise<MemberLight[]> {
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function getMembersFull(ids: number[]): Promise<Member[]> {
|
||||
const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id IN (?);`;
|
||||
const res: Member[] = await pool.query(sql, [ids]);
|
||||
return res;
|
||||
export async function getMembersFull(ids: number[]): Promise<MemberCardDetails[]> {
|
||||
const sql = `
|
||||
SELECT m.*,
|
||||
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> {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MemberLight } from '@app/shared/types/member';
|
||||
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) {
|
||||
|
||||
const sql = `INSERT INTO members_roles (member_id, role_id) VALUES (?, ?);`;
|
||||
const params = [userID, roleID];
|
||||
|
||||
@@ -24,4 +24,34 @@ export async function getUserRoles(userID: number): Promise<Role[]> {
|
||||
WHERE mr.member_id = ?;`;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface MemberCardDetails {
|
||||
member: Member;
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
export interface myData {
|
||||
member: Member;
|
||||
LOAs: LOARequest[];
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { MemberLight } from "./member";
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
color?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface RoleSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memberSettings, Member, MemberLight } from "@shared/types/member";
|
||||
import { memberSettings, Member, MemberLight, MemberCardDetails } from "@shared/types/member";
|
||||
|
||||
// @ts-ignore
|
||||
const addr = import.meta.env.VITE_APIHOST;
|
||||
@@ -71,7 +71,7 @@ export async function getLightMembers(ids: number[]): Promise<MemberLight[]> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getFullMembers(ids: number[]): Promise<Member[]> {
|
||||
export async function getFullMembers(ids: number[]): Promise<MemberCardDetails[]> {
|
||||
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
export type Role = {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string | null;
|
||||
members: any[];
|
||||
};
|
||||
import { Member, MemberLight } from "@shared/types/member";
|
||||
import { Role } from "@shared/types/roles";
|
||||
|
||||
// @ts-ignore
|
||||
const addr = import.meta.env.VITE_APIHOST;
|
||||
@@ -22,6 +17,30 @@ export async function getRoles(): Promise<Role[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRoleDetails(id: number): Promise<Role> {
|
||||
const res = await fetch(`${addr}/roles/${id}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
return res.json() as Promise<Role>;
|
||||
} else {
|
||||
throw new Error("Could not load role");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRoleMembers(id: number): Promise<MemberLight[]> {
|
||||
const res = await fetch(`${addr}/roles/${id}/members`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
} else {
|
||||
throw new Error("Could not load members");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRole(name: string, color: string, description: string | null): Promise<Role | null> {
|
||||
const res = await fetch(`${addr}/roles`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useMemberDirectory } from '@/stores/memberDirectory';
|
||||
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 PopoverTrigger from '../ui/popover/PopoverTrigger.vue';
|
||||
import PopoverContent from '../ui/popover/PopoverContent.vue';
|
||||
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils.js'
|
||||
import { watch } from 'vue';
|
||||
import { format } from 'path';
|
||||
import Spinner from '../ui/spinner/Spinner.vue';
|
||||
import { Dot } from 'lucide-vue-next';
|
||||
|
||||
|
||||
// Props
|
||||
@@ -21,7 +22,7 @@ const props = defineProps({
|
||||
|
||||
// Local state
|
||||
const memberLight = ref<MemberLight | null>(null);
|
||||
const memberFull = ref<Member | null>(null)
|
||||
const memberFull = ref<MemberCardDetails | null>(null)
|
||||
const loadingFull = ref(false)
|
||||
const membersStore = useMemberDirectory();
|
||||
|
||||
@@ -63,7 +64,7 @@ const hasFullInfo = computed(() => {
|
||||
if (!memberFull.value) return false
|
||||
|
||||
// check if any field has a value
|
||||
const { rank, unit, status } = memberFull.value
|
||||
const { rank, unit, status } = memberFull.value.member
|
||||
return !!(rank || unit || status)
|
||||
})
|
||||
|
||||
@@ -90,7 +91,7 @@ function formatDate(date: Date): string {
|
||||
{{ displayName }}
|
||||
</p>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-72 p-0 overflow-hidden">
|
||||
<PopoverContent class="w-80 p-0 overflow-hidden">
|
||||
<!-- Loading -->
|
||||
<div v-if="loadingFull" class="p-4 text-sm text-muted-foreground mx-auto flex justify-center my-5">
|
||||
<Spinner></Spinner>
|
||||
@@ -114,26 +115,36 @@ function formatDate(date: Date): string {
|
||||
<div class="p-4 space-y-3 text-sm">
|
||||
<!-- Full info -->
|
||||
<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">
|
||||
On Leave of Absence until {{ formatDate(memberFull.loa_until) }}
|
||||
On Leave of Absence until {{ formatDate(memberFull.member.loa_until) }}
|
||||
</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="font-medium">{{ memberFull.rank }}</span>
|
||||
<span class="font-medium">{{ memberFull.member.rank }}</span>
|
||||
</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="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 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="font-medium">{{ memberFull.status }}</span>
|
||||
<span class="font-medium">{{ memberFull.member.status }}</span>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
@@ -2,10 +2,10 @@ import { cva } from "class-variance-authority";
|
||||
|
||||
export { default as InputGroup } from "./InputGroup.vue";
|
||||
export { default as InputGroupAddon } from "./InputGroupAddon.vue";
|
||||
export { default as InputGroupButton } from "./InputGroupButton.vue";
|
||||
export { default as InputGroupInput } from "./InputGroupInput.vue";
|
||||
export { default as InputGroupText } from "./InputGroupText.vue";
|
||||
export { default as InputGroupTextarea } from "./InputGroupTextarea.vue";
|
||||
// export { default as InputGroupButton } from "./InputGroupButton.vue";
|
||||
// export { default as InputGroupInput } from "./InputGroupInput.vue";
|
||||
// export { default as InputGroupText } from "./InputGroupText.vue";
|
||||
// export { default as InputGroupTextarea } from "./InputGroupTextarea.vue";
|
||||
|
||||
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",
|
||||
|
||||
@@ -22,7 +22,7 @@ const modelValue = useVModel(props, "modelValue", emits, {
|
||||
data-slot="input"
|
||||
:class="
|
||||
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]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
props.class,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
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 {
|
||||
Dialog,
|
||||
@@ -34,8 +34,11 @@ import { Plus, X } from 'lucide-vue-next';
|
||||
import Separator from '@/components/ui/separator/Separator.vue';
|
||||
import Input from '@/components/ui/input/Input.vue';
|
||||
import Label from '@/components/ui/label/Label.vue';
|
||||
import { getMembers } from '@/api/member';
|
||||
import { Member } from '@shared/types/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 activeRole = ref<Role | null>(null)
|
||||
@@ -43,16 +46,9 @@ const showDialog = ref(false);
|
||||
const showCreateGroupDialog = ref(false);
|
||||
const addingMember = ref(false);
|
||||
const memberToAdd = ref<Member | null>(null);
|
||||
const route = useRoute();
|
||||
|
||||
const allMembers = ref<Member[]>([])
|
||||
const availableMembers = computed(() => {
|
||||
if (!activeRole.value) return [];
|
||||
return allMembers.value.filter(
|
||||
member => !activeRole.value!.members.some(
|
||||
roleMember => roleMember.member_id === member.member_id
|
||||
)
|
||||
);
|
||||
})
|
||||
const allMembers = ref<MemberLight[]>([])
|
||||
|
||||
type RoleDraft = {
|
||||
name: string
|
||||
@@ -117,141 +113,40 @@ async function handleCreateGroup() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddMember() {
|
||||
//guard
|
||||
if (memberToAdd.value == null)
|
||||
return;
|
||||
|
||||
await addMemberToRole(memberToAdd.value.member_id, activeRole.value.id);
|
||||
roles.value = await getRoles();
|
||||
}
|
||||
|
||||
async function handleRemoveMember(memberId: number) {
|
||||
removeMemberFromRole(memberId, activeRole.value.id);
|
||||
roles.value = await getRoles();
|
||||
}
|
||||
|
||||
async function handleDeleteRole() {
|
||||
await deleteRole(activeRole.value.id);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
roles.value = await getRoles();
|
||||
allMembers.value = await getMembers();
|
||||
allMembers.value = await getAllLightMembers();
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<Dialog v-model:open="showDialog"
|
||||
v-on:update:open="() => { showDialog = false; addingMember = false; memberToAdd = null; }">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex justify-between items-center">{{ activeRole.name }}
|
||||
<Badge class="mr-5">
|
||||
{{ activeRole.color }}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ activeRole.description }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="mt-5">
|
||||
<div class="flex justify-between items-center">
|
||||
<p>Members with this role</p>
|
||||
</div>
|
||||
<Separator class="my-2"></Separator>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<li v-for="member in activeRole.members" class="flex justify-between items-center">
|
||||
<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 class="max-w-7xl mx-auto w-full my-[0.2dvh] h-[0.8dvh]">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="scroll-m-20 text-2xl font-semibold tracking-tight my-5">Role Management</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(400px,280px)_1fr] gap-5">
|
||||
<div class="w-full pr-5 border-r">
|
||||
<div class="space-y-1">
|
||||
<p class="px-3 py-2 text-xs font-medium text-muted-foreground uppercase">
|
||||
Roles
|
||||
</p>
|
||||
|
||||
<button v-for="r in roles" :key="r.id" @click="$router.push(`/administration/roles/${r.id}`)"
|
||||
:class="[
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2 transition-colors cursor-pointer',
|
||||
Number(route.params.id) == r.id
|
||||
? 'bg-muted font-medium'
|
||||
: 'hover:bg-muted/50'
|
||||
]">
|
||||
<span class="h-2 w-2 rounded-full shrink-0" :style="{ backgroundColor: r.color }" />
|
||||
<span class="truncate">{{ r.name }}</span>
|
||||
</button>
|
||||
</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 class="grid grid-cols-3 gap-5">
|
||||
<Card v-for="value in roles" :key="value.id" @click="activeRole = value; showDialog = true"
|
||||
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>
|
||||
<RoleView :all-members="allMembers"></RoleView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,8 @@ const router = createRouter({
|
||||
{ path: 'applications/:id', component: () => import('@/pages/Application.vue') },
|
||||
{ path: 'transfer', component: () => import('@/pages/ManageTransfers.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 type { MemberLight, Member } from "@shared/types/member"
|
||||
import type { MemberLight, Member, MemberCardDetails } from "@shared/types/member"
|
||||
import { getLightMembers, getFullMembers } from "@/api/member"
|
||||
import { reactive, ref } from "vue"
|
||||
import { resolve } from "path"
|
||||
@@ -7,7 +7,7 @@ import { rejects } from "assert"
|
||||
|
||||
export const useMemberDirectory = defineStore('memberDirectory', () => {
|
||||
const light = reactive<Record<number, MemberLight>>({});
|
||||
const full = reactive<Record<number, Member>>({})
|
||||
const full = reactive<Record<number, MemberCardDetails>>({})
|
||||
|
||||
function getLight(id: number): Promise<MemberLight> {
|
||||
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 (!fullWaiters.has(id)) {
|
||||
@@ -34,7 +34,7 @@ export const useMemberDirectory = defineStore('memberDirectory', () => {
|
||||
|
||||
scheduleBatch()
|
||||
|
||||
return new Promise<Member>((resolve, reject) => {
|
||||
return new Promise<MemberCardDetails>((resolve, reject) => {
|
||||
fullWaiters.get(id)!.push({ resolve, reject })
|
||||
})
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export const useMemberDirectory = defineStore('memberDirectory', () => {
|
||||
|
||||
// promises
|
||||
const lightWaiters = new Map<number, Array<{ resolve: (m: MemberLight) => void; reject: (e: any) => void }>>()
|
||||
const fullWaiters = new Map<number, Array<{ resolve: (m: Member) => void; reject: (e: any) => void }>>()
|
||||
const fullWaiters = new Map<number, Array<{ resolve: (m: MemberCardDetails) => void; reject: (e: any) => void }>>()
|
||||
|
||||
let batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -105,12 +105,13 @@ export const useMemberDirectory = defineStore('memberDirectory', () => {
|
||||
try {
|
||||
const res = await getFullMembers(ids);
|
||||
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) {
|
||||
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