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:
2026-05-29 02:05:59 -05:00
parent f4902409c3
commit 42498f98c8
+90 -19
View File
@@ -2,7 +2,7 @@
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useUsersStore } from '@/stores/users' 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -31,15 +31,53 @@ import {
IconUserPlus, IconUserPlus,
IconUserMinus, IconUserMinus,
IconBuildingFactory, IconBuildingFactory,
IconArrowUp,
IconArrowDown,
IconGripVertical,
} from '@tabler/icons-vue' } from '@tabler/icons-vue'
const { t } = useI18n() const { t } = useI18n()
const store = useUsersStore() 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 ─────────────────────────────────────────────── // ─── Cells ───────────────────────────────────────────────
const cells = ref<(CellRecord & { memberCount: number })[]>([]) const cells = ref<(CellRecord & { memberCount: number })[]>([])
const expandedCell = ref<string | null>(null) const expandedCell = ref<string | null>(null)
const cellMembers = ref<Record<string, any[]>>({}) const cellMembers = ref<Record<string, CellMemberRecord[]>>({})
const showNewCell = ref(false) const showNewCell = ref(false)
const newCellName = ref('') const newCellName = ref('')
const newCellDesc = ref('') const newCellDesc = ref('')
@@ -48,6 +86,7 @@ const newCellType = ref<'normal' | 'transversal'>('normal')
// Add member // Add member
const addingToCell = ref<string | null>(null) const addingToCell = ref<string | null>(null)
const selectedUserId = ref<number | null>(null) const selectedUserId = ref<number | null>(null)
const selectedRole = ref<CellRole>('dev')
async function loadCells() { async function loadCells() {
cells.value = await getAllCellsWithCounts() cells.value = await getAllCellsWithCounts()
@@ -57,7 +96,9 @@ async function loadCells() {
} }
async function loadMembers(cellId: string) { 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) { function toggleCell(cellId: string) {
@@ -93,13 +134,20 @@ async function removeCell(id: string) {
async function addUserToCell(cellId: string) { async function addUserToCell(cellId: string) {
if (!selectedUserId.value) return 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 selectedUserId.value = null
selectedRole.value = 'dev'
addingToCell.value = null addingToCell.value = null
await loadMembers(cellId) await loadMembers(cellId)
await loadCells() await loadCells()
} }
function startAddMember(cellId: string) {
selectedUserId.value = null
selectedRole.value = 'dev'
addingToCell.value = cellId
}
async function removeUserFromCell(cellId: string, userId: number) { async function removeUserFromCell(cellId: string, userId: number) {
await removeMember(cellId, userId) await removeMember(cellId, userId)
await loadMembers(cellId) await loadMembers(cellId)
@@ -114,6 +162,10 @@ function userName(id: number): string {
return store.users.find(u => u.id === id)?.full_name || `Usuario ${id}` 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 ─────────────────────────────────────────────── // ─── Table ───────────────────────────────────────────────
const searchQuery = ref('') const searchQuery = ref('')
const currentPage = ref(1) const currentPage = ref(1)
@@ -258,27 +310,46 @@ onMounted(() => {
</CardHeader> </CardHeader>
<!-- Expanded members --> <!-- Expanded members -->
<CardContent v-if="expandedCell === c.id" class="p-4 pt-2 space-y-2" @click.stop> <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-if="cellMembers[c.id]?.length" class="space-y-1">
<div v-for="m in cellMembers[c.id]" :key="m.userId" class="flex items-center justify-between py-1 text-sm"> <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">
<span>{{ userName(m.userId) }}</span> <div class="flex items-center gap-2 min-w-0">
<Badge variant="outline" class="text-[10px]">{{ m.roleInCell }}</Badge> <span class="truncate">{{ userName(m.userId) }}</span>
<Button variant="ghost" size="sm" class="size-6" @click="removeUserFromCell(c.id, m.userId)"> <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" /> <IconUserMinus class="size-3" />
</Button> </Button>
</div> </div>
</div> </div>
<p v-else class="text-xs text-muted-foreground italic">Sin miembros</p> <p v-else class="text-xs text-muted-foreground italic">Sin miembros</p>
<div v-if="addingToCell === c.id" class="flex gap-2"> <div v-if="addingToCell === c.id" class="space-y-2 pt-1">
<Select v-model="selectedUserId"> <div class="flex gap-2">
<SelectTrigger class="flex-1 h-8 text-xs"><SelectValue placeholder="Seleccionar usuario..." /></SelectTrigger> <Select v-model="selectedUserId">
<SelectContent> <SelectTrigger class="flex-1 h-8 text-xs"><SelectValue placeholder="Seleccionar usuario..." /></SelectTrigger>
<SelectItem v-for="u in store.users" :key="u.id" :value="u.id" class="text-xs">{{ u.full_name }}</SelectItem> <SelectContent>
</SelectContent> <SelectItem v-for="u in store.users" :key="u.id" :value="u.id" class="text-xs">{{ u.full_name }}</SelectItem>
</Select> </SelectContent>
<Button size="sm" class="h-8 text-xs" :disabled="!selectedUserId" @click="addUserToCell(c.id)">Añadir</Button> </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>
<div class="flex gap-1"> <div class="flex gap-1 pt-1">
<Button v-if="addingToCell !== c.id" size="sm" variant="outline" class="h-7 text-xs" @click="addingToCell = c.id"> <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 <IconUserPlus class="size-3 mr-1" /> Añadir miembro
</Button> </Button>
<Button size="sm" variant="ghost" class="h-7 text-xs text-destructive" @click="removeCell(c.id)"> <Button size="sm" variant="ghost" class="h-7 text-xs text-destructive" @click="removeCell(c.id)">