255 lines
9.7 KiB
Vue
255 lines
9.7 KiB
Vue
<script setup lang="ts">
|
|
import Button from '@/components/ui/button/Button.vue';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card'
|
|
import { onMounted, ref, computed, reactive, watch } from 'vue';
|
|
import { addMemberToRole, createRole, deleteRole, getRoles, removeMemberFromRole, Role } from '@/api/roles';
|
|
import Badge from '@/components/ui/badge/Badge.vue';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
|
|
import {
|
|
Combobox,
|
|
ComboboxAnchor,
|
|
ComboboxEmpty,
|
|
ComboboxGroup,
|
|
ComboboxInput,
|
|
ComboboxItem,
|
|
ComboboxItemIndicator,
|
|
ComboboxList,
|
|
} from '@/components/ui/combobox'
|
|
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, Member } from '@/api/member';
|
|
|
|
const roles = ref<Role[]>([])
|
|
const activeRole = ref<Role | null>(null)
|
|
const showDialog = ref(false);
|
|
const showCreateGroupDialog = ref(false);
|
|
const addingMember = ref(false);
|
|
const memberToAdd = ref<Member | null>(null);
|
|
|
|
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
|
|
)
|
|
);
|
|
})
|
|
|
|
type RoleDraft = {
|
|
name: string
|
|
description: string
|
|
color: string // whatever you store, e.g. a hex string or a semantic tag
|
|
}
|
|
|
|
const roleDraft = reactive<RoleDraft>({
|
|
name: "",
|
|
description: "",
|
|
color: "", // e.g. "#FF8A00" or "orange"
|
|
})
|
|
|
|
const draftErrors = reactive<{ name?: string; color?: string }>({})
|
|
const creating = ref(false)
|
|
|
|
function resetRoleDraft() {
|
|
roleDraft.name = ""
|
|
roleDraft.description = ""
|
|
roleDraft.color = ""
|
|
draftErrors.name = undefined
|
|
draftErrors.color = undefined
|
|
}
|
|
|
|
watch(showCreateGroupDialog, (open) => {
|
|
if (!open) resetRoleDraft()
|
|
})
|
|
|
|
|
|
function validateRoleDraft(): boolean {
|
|
draftErrors.name = !roleDraft.name.trim() ? "Group name is required" : undefined
|
|
// If color is required or must be hex, validate it here:
|
|
if (!roleDraft.color.trim()) {
|
|
draftErrors.color = "Color is required"
|
|
} else if (!/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(roleDraft.color)) {
|
|
draftErrors.color = "Use a valid hex color like #FF8A00"
|
|
} else {
|
|
draftErrors.color = undefined
|
|
}
|
|
|
|
return !draftErrors.name && !draftErrors.color
|
|
}
|
|
|
|
async function handleCreateGroup() {
|
|
if (!validateRoleDraft()) return
|
|
|
|
try {
|
|
creating.value = true
|
|
|
|
// If your createRole API already accepts a payload:
|
|
await createRole(roleDraft.name, roleDraft.color, roleDraft.description)
|
|
|
|
// Refresh list, close, reset
|
|
roles.value = await getRoles()
|
|
showCreateGroupDialog.value = false
|
|
resetRoleDraft()
|
|
} catch (err) {
|
|
console.error("Failed to create role:", err)
|
|
// optionally surface a toast/error UI here
|
|
} finally {
|
|
creating.value = false
|
|
}
|
|
}
|
|
|
|
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 () => {
|
|
roles.value = await getRoles();
|
|
allMembers.value = await getMembers();
|
|
})
|
|
</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>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</template> |