UsersView: jerarquía de roles en células (PM → BA → TL → Dev → QA → Asistente)
- Roles ordenados por jerarquía: PM, BA, TL, Dev, QA, Asistente - Selector de rol al añadir miembro a una célula - Badges con color por rol (morado, azul, ámbar, verde, rosa, celeste) - Miembros ordenados por jerarquía dentro de cada célula - Botón de eliminar miembro visible solo al hover (interfaz más limpia)
This commit is contained in:
+90
-19
@@ -2,7 +2,7 @@
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { getAllCellsWithCounts, getMembers, saveCell, deleteCell, addMember, removeMember, createCellId, type CellRecord } from '@/services/cells-db'
|
||||
import { getAllCellsWithCounts, getMembers, saveCell, deleteCell, addMember, removeMember, createCellId, type CellRecord, type CellMemberRecord } from '@/services/cells-db'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -31,15 +31,53 @@ import {
|
||||
IconUserPlus,
|
||||
IconUserMinus,
|
||||
IconBuildingFactory,
|
||||
IconArrowUp,
|
||||
IconArrowDown,
|
||||
IconGripVertical,
|
||||
} from '@tabler/icons-vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useUsersStore()
|
||||
|
||||
// ─── Role hierarchy ──────────────────────────────────────
|
||||
const ROLE_HIERARCHY = ['pm', 'ba', 'tl', 'dev', 'qa', 'asistente'] as const
|
||||
type CellRole = (typeof ROLE_HIERARCHY)[number]
|
||||
|
||||
const ROLE_LABELS: Record<CellRole, string> = {
|
||||
pm: 'PM',
|
||||
ba: 'BA',
|
||||
tl: 'TL',
|
||||
dev: 'Dev',
|
||||
qa: 'QA',
|
||||
asistente: 'Asistente',
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<CellRole, string> = {
|
||||
pm: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
ba: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
tl: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
dev: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
qa: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400',
|
||||
asistente: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400',
|
||||
}
|
||||
|
||||
function roleOrder(role: string): number {
|
||||
const idx = ROLE_HIERARCHY.indexOf(role.toLowerCase() as CellRole)
|
||||
return idx >= 0 ? idx : 99
|
||||
}
|
||||
|
||||
function roleLabel(role: string): string {
|
||||
return ROLE_LABELS[role.toLowerCase() as CellRole] || role
|
||||
}
|
||||
|
||||
function roleColor(role: string): string {
|
||||
return ROLE_COLORS[role.toLowerCase() as CellRole] || 'bg-muted text-muted-foreground'
|
||||
}
|
||||
|
||||
// ─── Cells ───────────────────────────────────────────────
|
||||
const cells = ref<(CellRecord & { memberCount: number })[]>([])
|
||||
const expandedCell = ref<string | null>(null)
|
||||
const cellMembers = ref<Record<string, any[]>>({})
|
||||
const cellMembers = ref<Record<string, CellMemberRecord[]>>({})
|
||||
const showNewCell = ref(false)
|
||||
const newCellName = ref('')
|
||||
const newCellDesc = ref('')
|
||||
@@ -48,6 +86,7 @@ const newCellType = ref<'normal' | 'transversal'>('normal')
|
||||
// Add member
|
||||
const addingToCell = ref<string | null>(null)
|
||||
const selectedUserId = ref<number | null>(null)
|
||||
const selectedRole = ref<CellRole>('dev')
|
||||
|
||||
async function loadCells() {
|
||||
cells.value = await getAllCellsWithCounts()
|
||||
@@ -57,7 +96,9 @@ async function loadCells() {
|
||||
}
|
||||
|
||||
async function loadMembers(cellId: string) {
|
||||
cellMembers.value[cellId] = await getMembers(cellId)
|
||||
const members = await getMembers(cellId)
|
||||
members.sort((a, b) => roleOrder(a.roleInCell) - roleOrder(b.roleInCell))
|
||||
cellMembers.value[cellId] = members
|
||||
}
|
||||
|
||||
function toggleCell(cellId: string) {
|
||||
@@ -93,13 +134,20 @@ async function removeCell(id: string) {
|
||||
|
||||
async function addUserToCell(cellId: string) {
|
||||
if (!selectedUserId.value) return
|
||||
await addMember({ cellId, userId: selectedUserId.value, roleInCell: 'dev', addedAt: new Date().toISOString() })
|
||||
await addMember({ cellId, userId: selectedUserId.value, roleInCell: selectedRole.value, addedAt: new Date().toISOString() })
|
||||
selectedUserId.value = null
|
||||
selectedRole.value = 'dev'
|
||||
addingToCell.value = null
|
||||
await loadMembers(cellId)
|
||||
await loadCells()
|
||||
}
|
||||
|
||||
function startAddMember(cellId: string) {
|
||||
selectedUserId.value = null
|
||||
selectedRole.value = 'dev'
|
||||
addingToCell.value = cellId
|
||||
}
|
||||
|
||||
async function removeUserFromCell(cellId: string, userId: number) {
|
||||
await removeMember(cellId, userId)
|
||||
await loadMembers(cellId)
|
||||
@@ -114,6 +162,10 @@ function userName(id: number): string {
|
||||
return store.users.find(u => u.id === id)?.full_name || `Usuario ${id}`
|
||||
}
|
||||
|
||||
function sortedMembers(members: CellMemberRecord[]) {
|
||||
return [...members].sort((a, b) => roleOrder(a.roleInCell) - roleOrder(b.roleInCell))
|
||||
}
|
||||
|
||||
// ─── Table ───────────────────────────────────────────────
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
@@ -258,27 +310,46 @@ onMounted(() => {
|
||||
</CardHeader>
|
||||
<!-- Expanded members -->
|
||||
<CardContent v-if="expandedCell === c.id" class="p-4 pt-2 space-y-2" @click.stop>
|
||||
<div v-if="cellMembers[c.id]?.length">
|
||||
<div v-for="m in cellMembers[c.id]" :key="m.userId" class="flex items-center justify-between py-1 text-sm">
|
||||
<span>{{ userName(m.userId) }}</span>
|
||||
<Badge variant="outline" class="text-[10px]">{{ m.roleInCell }}</Badge>
|
||||
<Button variant="ghost" size="sm" class="size-6" @click="removeUserFromCell(c.id, m.userId)">
|
||||
<div v-if="cellMembers[c.id]?.length" class="space-y-1">
|
||||
<div v-for="m in sortedMembers(cellMembers[c.id])" :key="m.userId" class="group flex items-center justify-between rounded-md px-2 py-1.5 text-sm hover:bg-muted/50 transition-colors">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="truncate">{{ userName(m.userId) }}</span>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium shrink-0" :class="roleColor(m.roleInCell)">
|
||||
{{ roleLabel(m.roleInCell) }}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="size-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" @click="removeUserFromCell(c.id, m.userId)">
|
||||
<IconUserMinus class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-muted-foreground italic">Sin miembros</p>
|
||||
<div v-if="addingToCell === c.id" class="flex gap-2">
|
||||
<Select v-model="selectedUserId">
|
||||
<SelectTrigger class="flex-1 h-8 text-xs"><SelectValue placeholder="Seleccionar usuario..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="u in store.users" :key="u.id" :value="u.id" class="text-xs">{{ u.full_name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" class="h-8 text-xs" :disabled="!selectedUserId" @click="addUserToCell(c.id)">Añadir</Button>
|
||||
<div v-if="addingToCell === c.id" class="space-y-2 pt-1">
|
||||
<div class="flex gap-2">
|
||||
<Select v-model="selectedUserId">
|
||||
<SelectTrigger class="flex-1 h-8 text-xs"><SelectValue placeholder="Seleccionar usuario..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="u in store.users" :key="u.id" :value="u.id" class="text-xs">{{ u.full_name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select v-model="selectedRole">
|
||||
<SelectTrigger class="w-28 h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="role in ROLE_HIERARCHY" :key="role" :value="role" class="text-xs">
|
||||
{{ ROLE_LABELS[role] }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" class="h-8 text-xs flex-1" :disabled="!selectedUserId" @click="addUserToCell(c.id)">
|
||||
<IconUserPlus class="size-3 mr-1" /> Añadir
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-8 text-xs" @click="addingToCell = null">Cancelar</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Button v-if="addingToCell !== c.id" size="sm" variant="outline" class="h-7 text-xs" @click="addingToCell = c.id">
|
||||
<div class="flex gap-1 pt-1">
|
||||
<Button v-if="addingToCell !== c.id" size="sm" variant="outline" class="h-7 text-xs" @click="startAddMember(c.id)">
|
||||
<IconUserPlus class="size-3 mr-1" /> Añadir miembro
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" class="h-7 text-xs text-destructive" @click="removeCell(c.id)">
|
||||
|
||||
Reference in New Issue
Block a user