cells: modulo de celulas + miembros + reemplazo de roles en UsersView

- db.ts v7: tablas cells + cell_members con compound key
- cells-db.ts: CRUD celulas, miembros, getAllCellsWithCounts
- UsersView: rediseñado con cards de celulas en vez de roles
- UsersView: crear/editar/eliminar celulas (normal/transversal)
- UsersView: añadir/remover miembros por celula
- UsersView: tabla de usuarios muestra celulas a las que pertenece
This commit is contained in:
2026-05-29 01:30:50 -05:00
parent 63770685da
commit 388fa09f3e
8 changed files with 340 additions and 253 deletions
-8
View File
@@ -363,14 +363,6 @@ async fn get_conn(db_path: &str) -> Result<libsql::Connection, String> {
let _ = conn.execute(alter, ()).await;
}
// Copiar DB al Desktop para acceso rápido con DBeaver
let _ = std::fs::copy(
db_path,
dirs_next::desktop_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("alpha.db"),
);
Ok(conn)
}
+2
View File
@@ -145,6 +145,8 @@
"teamMembers": "Team members · {count}",
"projectCount": "{count} project | {count} projects",
"allUsers": "All users",
"allRoles": "All roles",
"allProjects": "All projects",
"name": "Name",
"email": "Email",
"role": "Role",
+2
View File
@@ -145,6 +145,8 @@
"teamMembers": "Miembros del equipo · {count}",
"projectCount": "{count} proyecto | {count} proyectos",
"allUsers": "Todos los usuarios",
"allRoles": "Todos los roles",
"allProjects": "Todos los proyectos",
"name": "Nombre",
"email": "Email",
"role": "Rol",
+65
View File
@@ -0,0 +1,65 @@
import db, { type CellRecord, type CellMemberRecord } from '@/services/db'
export type { CellRecord, CellMemberRecord }
// ─── Cells ──────────────────────────────────────────────
export async function getCells(): Promise<CellRecord[]> {
return db.cells.toArray()
}
export async function getCell(id: string): Promise<CellRecord | undefined> {
return db.cells.get(id)
}
export async function saveCell(c: CellRecord) {
await db.cells.put(c)
}
export async function deleteCell(id: string) {
await db.cells.delete(id)
await db.cell_members.where('cellId').equals(id).delete()
}
export function createCellId(): string {
return crypto.randomUUID()
}
// ─── Cell Members ─────────────────────────────────────────
export async function getMembers(cellId: string): Promise<CellMemberRecord[]> {
return db.cell_members.where('cellId').equals(cellId).toArray()
}
export async function addMember(m: CellMemberRecord) {
await db.cell_members.put(m)
}
export async function removeMember(cellId: string, userId: number) {
await db.table('cell_members').delete([cellId, userId] as any)
}
export async function getUserCells(userId: number): Promise<CellRecord[]> {
const members = await db.cell_members.where('userId').equals(userId).toArray()
const cellIds = members.map(m => m.cellId)
const cells: CellRecord[] = []
for (const id of cellIds) {
const c = await db.cells.get(id)
if (c) cells.push(c)
}
return cells
}
export async function getCellWithMembers(cellId: string): Promise<{ cell: CellRecord | undefined; members: CellMemberRecord[] }> {
const [cell, members] = await Promise.all([db.cells.get(cellId), db.cell_members.where('cellId').equals(cellId).toArray()])
return { cell, members }
}
export async function getAllCellsWithCounts(): Promise<(CellRecord & { memberCount: number })[]> {
const cells = await db.cells.toArray()
const counts = await db.cell_members.toArray()
const countMap = new Map<string, number>()
for (const m of counts) {
countMap.set(m.cellId, (countMap.get(m.cellId) || 0) + 1)
}
return cells.map(c => ({ ...c, memberCount: countMap.get(c.id) || 0 }))
}
+20 -1
View File
@@ -96,6 +96,21 @@ export interface LookupRecord {
description: string | null
}
export interface CellRecord {
id: string // UUID
name: string
description: string
type: 'normal' | 'transversal'
createdAt: string
}
export interface CellMemberRecord {
cellId: string
userId: number
roleInCell: string // 'dev' | 'qa' | 'ba' | 'pm' | 'tl'
addedAt: string
}
const db = new Dexie('alpha-core') as Dexie & {
settings: Dexie.Table<SettingEntry, string>
project_docs: Dexie.Table<ProjectDocRecord, number>
@@ -106,9 +121,11 @@ const db = new Dexie('alpha-core') as Dexie & {
qa_plans: Dexie.Table<QAPlanRecord, string>
users: Dexie.Table<UserRecord, number>
lookups: Dexie.Table<LookupRecord, string>
cells: Dexie.Table<CellRecord, string>
cell_members: Dexie.Table<CellMemberRecord, string>
}
db.version(6).stores({
db.version(7).stores({
settings: '&key',
project_docs: '&projectId, projectName, updatedAt',
sessions: '++id, projectId, date',
@@ -118,6 +135,8 @@ db.version(6).stores({
qa_plans: '&id, projectId',
users: '&id',
lookups: '&[type+id], type',
cells: '&id',
cell_members: '[cellId+userId], cellId, userId',
})
export default db
+1
View File
@@ -1,4 +1,5 @@
import db, { type UserRecord, type LookupRecord } from '@/services/db'
export type { UserRecord, LookupRecord }
// ─── Users ───────────────────────────────────────────────
+38 -4
View File
@@ -3,7 +3,7 @@ import { ref, computed } from 'vue'
import { kappa } from '@/services/kappa-api'
import { tauriDb, type AlphaUserRecord, type CellRecord } from '@/services/tauri-db'
import { syncUserDetails } from '@/services/user-sync'
import { getLookups, getUsers } from '@/services/users-db'
import { getLookups, getUsers, type UserRecord } from '@/services/users-db'
import type { KappaEmployee } from '@/types/kappa'
export interface AlphaUser {
@@ -48,8 +48,34 @@ export const useUsersStore = defineStore('users', () => {
})
async function fetchAll() {
loading.value = true
error.value = null
// 1. Cache hit: mostrar desde Dexie inmediatamente, saltar skeleton
const dbUsers = await getUsers().catch(() => [] as UserRecord[])
const roleItems = await getLookups('role').catch(() => [] as { id: number; name: string }[])
if (dbUsers.length > 0) {
users.value = dbUsers.map(u => ({
id: u.id,
email: u.email,
first_name: u.firstName,
last_name: u.lastName,
full_name: u.fullName,
is_active: u.isActive,
role: roleItems.find(r => r.id === u.roleId)?.name || undefined,
cell: undefined,
seniority: u.seniority || undefined,
projects: [],
projects_count: 0,
cell_id: undefined,
employee_ref: undefined,
}))
loading.value = false
} else {
loading.value = true
}
// 2. Background refresh desde API
try {
const [rawUsers, page1, localUsers, localCells] = await Promise.all([
kappa.getUsers(),
@@ -76,10 +102,18 @@ export const useUsersStore = defineStore('users', () => {
const localMap = new Map(localUsers.map(u => [u.id, u]))
users.value = buildUsers(rawUsers, employees.value, localMap)
// Sync KAPPA users into Turso
// 3. Restaurar roles desde Dexie
for (const u of users.value) {
const record = dbUsers.find(d => d.id === u.id)
if (record?.roleId) {
const role = roleItems.find(r => r.id === record.roleId)
if (role) u.role = role.name
}
}
syncUsersToTurso(users.value)
} catch (e: any) {
error.value = e.message
if (!dbUsers.length) error.value = e.message
} finally {
loading.value = false
}
+174 -202
View File
@@ -2,11 +2,19 @@
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
@@ -18,72 +26,101 @@ import {
import {
IconExclamationCircle,
IconUsers,
IconCode,
IconClipboardText,
IconUserCog,
IconPlus,
IconTrash,
IconUserPlus,
IconUserMinus,
IconBuildingFactory,
IconChevronLeft,
IconChevronRight,
IconSearch,
IconRefresh,
} from '@tabler/icons-vue'
const { t } = useI18n()
const store = useUsersStore()
const roleIcons: Record<string, any> = {
DEV: IconCode,
BA: IconClipboardText,
PM: IconUserCog,
PO: IconBuildingFactory,
QA: IconCode,
TL: IconUserCog,
// Cells
const cells = ref<(CellRecord & { memberCount: number })[]>([])
const expandedCell = ref<string | null>(null)
const cellMembers = ref<Record<string, any[]>>({})
const showNewCell = ref(false)
const newCellName = ref('')
const newCellDesc = ref('')
const newCellType = ref<'normal' | 'transversal'>('normal')
// Add member
const addingToCell = ref<string | null>(null)
const selectedUserId = ref<number | null>(null)
async function loadCells() {
cells.value = await getAllCellsWithCounts()
for (const c of cells.value) {
loadMembers(c.id)
}
}
const roleColors: Record<string, string> = {
DEV: 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950',
BA: 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950',
PM: 'text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-950',
PO: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950',
QA: 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950',
TL: 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-950',
async function loadMembers(cellId: string) {
cellMembers.value[cellId] = await getMembers(cellId)
}
const badgeColors: Record<string, string> = {
DEV: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
BA: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
PM: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
PO: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400',
QA: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
TL: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
function toggleCell(cellId: string) {
if (expandedCell.value === cellId) {
expandedCell.value = null
} else {
expandedCell.value = cellId
loadMembers(cellId)
}
}
const teamRoles = ['DEV', 'BA', 'PM', 'PO', 'QA', 'TL']
const PAGE_SIZE = 15
async function createCell() {
if (!newCellName.value.trim()) return
await saveCell({
id: createCellId(),
name: newCellName.value.trim(),
description: newCellDesc.value.trim(),
type: newCellType.value,
createdAt: new Date().toISOString(),
})
newCellName.value = ''
newCellDesc.value = ''
newCellType.value = 'normal'
showNewCell.value = false
await loadCells()
}
const teamMembers = computed(() => store.users.filter(u => teamRoles.includes(u.role || '')))
async function removeCell(id: string) {
await deleteCell(id)
if (expandedCell.value === id) expandedCell.value = null
await loadCells()
}
const stats = computed(() => ({
total: store.users.length,
active: store.activeUsers.length,
byRole: teamRoles.reduce((acc, role) => {
acc[role] = store.users.filter(u => u.role === role).length
return acc
}, {} as Record<string, number>),
}))
async function addUserToCell(cellId: string) {
if (!selectedUserId.value) return
await addMember({ cellId, userId: selectedUserId.value, roleInCell: 'dev', addedAt: new Date().toISOString() })
selectedUserId.value = null
addingToCell.value = null
await loadMembers(cellId)
await loadCells()
}
// Native paginated table
async function removeUserFromCell(cellId: string, userId: number) {
await removeMember(cellId, userId)
await loadMembers(cellId)
await loadCells()
}
function userName(id: number): string {
return store.users.find(u => u.id === id)?.full_name || `Usuario ${id}`
}
// Table
const searchQuery = ref('')
const currentPage = ref(1)
const PAGE_SIZE = 15
const filteredUsers = computed(() => {
const q = searchQuery.value.toLowerCase().trim()
if (!q) return store.users
return store.users.filter(u =>
u.full_name?.toLowerCase().includes(q) ||
u.email?.toLowerCase().includes(q) ||
u.role?.toLowerCase().includes(q) ||
u.cell?.toLowerCase().includes(q)
u.email?.toLowerCase().includes(q)
)
})
@@ -94,15 +131,14 @@ const paginatedUsers = computed(() => {
return filteredUsers.value.slice(start, start + PAGE_SIZE)
})
function goToPage(p: number) {
currentPage.value = Math.max(1, Math.min(p, totalPages.value))
}
function initials(u: { first_name?: string; last_name?: string }) {
return `${(u.first_name || '')[0] || ''}${(u.last_name || '')[0] || ''}`
}
onMounted(() => store.fetchAll())
onMounted(() => {
store.fetchAll()
loadCells()
})
</script>
<template>
@@ -111,145 +147,118 @@ onMounted(() => store.fetchAll())
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">{{ t('users.teamTitle') }}</h2>
<p class="text-muted-foreground">
{{ t('users.teamSubtitle', { users: store.users.length, emps: store.employees.length }) }}
</p>
<p class="text-sm text-muted-foreground">{{ store.users.length }} usuarios · {{ cells.length }} células</p>
</div>
<div class="flex items-center gap-2">
<Button v-if="!store.syncing" size="sm" variant="outline" @click="store.syncDetails()">
<IconRefresh class="size-4 mr-1" />
Sincronizar roles
<Button size="sm" @click="showNewCell = !showNewCell">
<IconPlus class="size-4 mr-1" />
Nueva célula
</Button>
<span v-if="store.syncing" class="text-xs text-muted-foreground">
Sincronizando {{ store.syncProgress }}/{{ store.syncTotal }}...
</span>
<Badge variant="outline" class="text-sm">
{{ t('users.activeCount', { count: stats.active }) }}
</Badge>
</div>
</div>
<!-- Loading -->
<template v-if="store.loading">
<div class="grid grid-cols-1 gap-4 @xl:grid-cols-2 @3xl:grid-cols-4">
<Skeleton v-for="i in 4" :key="i" class="h-24 rounded-xl" />
<!-- New cell form -->
<Card v-if="showNewCell" class="border-dashed">
<CardContent class="p-4 space-y-3">
<Input v-model="newCellName" placeholder="Nombre de la célula" />
<Input v-model="newCellDesc" placeholder="Descripción (opcional)" />
<div class="flex items-center gap-3">
<Select v-model="newCellType">
<SelectTrigger class="w-40"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="transversal">Transversal</SelectItem>
</SelectContent>
</Select>
<Button size="sm" @click="createCell" :disabled="!newCellName.trim()">Crear</Button>
<Button size="sm" variant="outline" @click="showNewCell = false">Cancelar</Button>
</div>
<div class="grid grid-cols-1 gap-4 @xl:grid-cols-2 @3xl:grid-cols-3">
<Skeleton v-for="i in 6" :key="i" class="h-40 rounded-xl" />
</CardContent>
</Card>
<!-- Loading -->
<template v-if="store.loading && cells.length === 0">
<div class="grid grid-cols-1 gap-3 @xl:grid-cols-2 @3xl:grid-cols-3">
<Skeleton v-for="i in 3" :key="i" class="h-28 rounded-xl" />
</div>
</template>
<!-- Error -->
<template v-else-if="store.error">
<div class="flex flex-col items-center gap-3 py-12 text-center">
<template v-else-if="store.error && cells.length === 0">
<div class="flex flex-col items-center gap-3 py-12">
<IconExclamationCircle class="size-10 text-destructive" />
<p class="text-lg font-medium">{{ t('users.loadError') }}</p>
<p class="text-sm text-muted-foreground">{{ store.error }}</p>
<button
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
@click="store.fetchAll()"
>
{{ t('common.retry') }}
</button>
<Button variant="outline" @click="store.fetchAll()">{{ t('common.retry') }}</Button>
</div>
</template>
<!-- Empty -->
<template v-else-if="store.users.length === 0">
<div class="flex flex-col items-center gap-3 py-12 text-center">
<IconUsers class="size-10 text-muted-foreground" />
<p class="text-lg font-medium">{{ t('users.emptyTitle') }}</p>
<p class="text-sm text-muted-foreground">{{ t('users.emptyDescription') }}</p>
</div>
</template>
<!-- Content -->
<template v-else>
<!-- Stats -->
<div id="users-stats" class="grid grid-cols-2 gap-3 @xl:grid-cols-3 @3xl:grid-cols-6">
<Card v-for="role in teamRoles" :key="role" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
<CardHeader class="p-4 pb-2">
<CardTitle class="flex items-center gap-2 text-sm font-medium">
<component
:is="roleIcons[role]"
class="size-4"
:class="roleColors[role]?.split(' ')[0]"
/>
{{ role }}
</CardTitle>
</CardHeader>
<CardContent class="p-4 pt-0">
<p class="text-2xl font-bold">{{ stats.byRole[role] || 0 }}</p>
</CardContent>
</Card>
</div>
<!-- Team Member Cards -->
<div>
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">
{{ t('users.teamMembers', { count: teamMembers.length }) }}
</h3>
<div id="users-team-cards" class="grid grid-cols-1 gap-3 @xl:grid-cols-2 @3xl:grid-cols-3">
<!-- Cell cards -->
<div v-if="cells.length > 0" class="grid grid-cols-1 gap-3 @xl:grid-cols-2 @3xl:grid-cols-3">
<Card
v-for="user in teamMembers"
:key="user.id"
class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card hover:border-primary/30 transition-colors"
v-for="c in cells"
:key="c.id"
class="cursor-pointer transition-colors hover:border-primary/40"
:class="expandedCell === c.id ? 'border-primary' : ''"
@click="toggleCell(c.id)"
>
<CardHeader class="p-4 pb-2">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div
class="flex items-center justify-center size-10 rounded-full text-sm font-bold"
:class="roleColors[user.role || ''] || 'bg-muted text-muted-foreground'"
>
{{ user.first_name?.[0] || '' }}{{ user.last_name?.[0] || '' }}
<CardHeader class="p-4 pb-2 flex flex-row items-start justify-between">
<div class="space-y-1">
<CardTitle class="text-sm font-medium flex items-center gap-2">
<IconBuildingFactory v-if="c.type === 'transversal'" class="size-4 text-amber-500" />
<IconUsers v-else class="size-4 text-muted-foreground" />
{{ c.name }}
</CardTitle>
<p v-if="c.description" class="text-xs text-muted-foreground">{{ c.description }}</p>
</div>
<div>
<p class="font-semibold text-sm">{{ user.full_name }}</p>
<p class="text-xs text-muted-foreground">{{ user.email }}</p>
</div>
</div>
<Badge
v-if="user.role"
:class="badgeColors[user.role] || ''"
variant="secondary"
class="text-xs"
>
{{ user.role }}
<div class="flex items-center gap-2 shrink-0">
<Badge variant="outline" class="text-[10px]" :class="c.type === 'transversal' ? 'border-amber-300 text-amber-600' : ''">
{{ c.type === 'transversal' ? 'Transversal' : 'Normal' }}
</Badge>
<span class="text-xs text-muted-foreground">{{ c.memberCount }}</span>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span v-if="user.cell" class="inline-flex items-center gap-1 rounded-md border px-2 py-0.5">
{{ user.cell }}
</span>
<span v-if="user.seniority" class="capitalize">{{ user.seniority }}</span>
<span class="font-medium text-foreground">{{ t('users.projectCount', { count: user.projects_count }) }}</span>
<!-- 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)">
<IconUserMinus class="size-3" />
</Button>
</div>
<div v-if="user.projects.length" class="flex flex-wrap gap-1 mt-2">
<span
v-for="p in user.projects.slice(0, 3)"
:key="p"
class="inline-flex rounded-full bg-muted px-2 py-0.5 text-xs"
>
{{ p }}
</span>
<span v-if="user.projects.length > 3" class="text-xs text-muted-foreground">
+{{ user.projects.length - 3 }}
</span>
</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>
<div class="flex gap-1">
<Button v-if="addingToCell !== c.id" size="sm" variant="outline" class="h-7 text-xs" @click="addingToCell = 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)">
<IconTrash class="size-3 mr-1" /> Eliminar
</Button>
</div>
</CardContent>
</Card>
</div>
<!-- Empty cells -->
<div v-if="!store.loading && cells.length === 0" class="text-center py-8 text-sm text-muted-foreground">
<IconUsers class="size-10 mx-auto mb-2 text-muted-foreground/50" />
<p>Sin células creadas. Creá una para organizar tu equipo.</p>
</div>
<!-- Native table -->
<!-- Users table -->
<Card>
<CardHeader class="pb-3 flex flex-row items-center justify-between gap-4">
<CardTitle class="text-sm font-medium">{{ t('users.allUsers') }}</CardTitle>
<div class="relative w-60">
<IconSearch class="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<CardHeader class="pb-3 flex flex-row items-center justify-between">
<CardTitle class="text-sm font-medium">{{ t('users.allUsers') }} ({{ store.users.length }})</CardTitle>
<div class="relative w-48">
<Input v-model="searchQuery" placeholder="Buscar..." class="pl-8 h-8 text-sm" @input="currentPage = 1" />
</div>
</CardHeader>
@@ -260,11 +269,7 @@ onMounted(() => store.fetchAll())
<TableHead class="w-[60px]">ID</TableHead>
<TableHead>{{ t('users.name') }}</TableHead>
<TableHead>{{ t('users.email') }}</TableHead>
<TableHead class="w-[80px]">{{ t('users.role') }}</TableHead>
<TableHead class="w-[100px]">{{ t('users.cell') }}</TableHead>
<TableHead class="w-[90px]">{{ t('users.seniority') }}</TableHead>
<TableHead class="w-[70px] text-right">{{ t('users.projects') }}</TableHead>
<TableHead>{{ t('users.assignments') }}</TableHead>
<TableHead>Células</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -272,55 +277,22 @@ onMounted(() => store.fetchAll())
<TableCell class="text-xs font-mono text-muted-foreground">{{ user.id }}</TableCell>
<TableCell>
<div class="flex items-center gap-2">
<div class="flex items-center justify-center size-7 rounded-full bg-primary/10 text-primary text-xs font-semibold shrink-0">
{{ initials(user) }}
</div>
<div class="flex items-center justify-center size-7 rounded-full bg-primary/10 text-primary text-xs font-semibold shrink-0">{{ initials(user) }}</div>
<span class="font-medium text-sm">{{ user.full_name }}</span>
</div>
</TableCell>
<TableCell class="text-sm text-muted-foreground">{{ user.email }}</TableCell>
<TableCell>
<Badge v-if="user.role" :class="badgeColors[user.role]" variant="secondary" class="text-[10px]">{{ user.role }}</Badge>
<span v-else class="text-muted-foreground text-sm"></span>
</TableCell>
<TableCell class="text-sm">{{ user.cell || '—' }}</TableCell>
<TableCell class="text-sm capitalize">{{ user.seniority || '—' }}</TableCell>
<TableCell class="text-sm text-right font-semibold">{{ user.projects_count || '—' }}</TableCell>
<TableCell>
<div class="flex flex-wrap gap-1">
<span v-for="p in user.projects.slice(0, 2)" :key="p" class="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px]">{{ p }}</span>
<span v-if="user.projects.length > 2" class="text-[10px] text-muted-foreground">+{{ user.projects.length - 2 }}</span>
<span v-if="!user.projects.length" class="text-muted-foreground text-sm"></span>
</div>
<span v-for="c in cells.filter(c => cellMembers[c.id]?.some(m => m.userId === user.id))" :key="c.id" class="inline-flex rounded-full bg-muted px-2 py-0.5 text-xs mr-1">{{ c.name }}</span>
<span v-if="!cells.some(c => cellMembers[c.id]?.some(m => m.userId === user.id))" class="text-xs text-muted-foreground"></span>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between px-4 py-3 border-t text-sm">
<span class="text-muted-foreground text-xs">
{{ filteredUsers.length }} usuarios · Página {{ currentPage }} de {{ totalPages }}
</span>
<div class="flex items-center gap-1">
<Button variant="outline" size="sm" class="size-8 p-0" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">
<IconChevronLeft class="size-4" />
</Button>
<Button
v-for="p in Math.min(totalPages, 5)"
:key="p"
variant="outline"
size="sm"
class="size-8 p-0 text-xs"
:class="p === currentPage ? 'bg-primary text-primary-foreground' : ''"
@click="goToPage(p)"
>{{ p }}</Button>
<Button variant="outline" size="sm" class="size-8 p-0" :disabled="currentPage >= totalPages" @click="goToPage(currentPage + 1)">
<IconChevronRight class="size-4" />
</Button>
</div>
<span class="text-muted-foreground text-xs">{{ filteredUsers.length }} usuarios · Página {{ currentPage }} de {{ totalPages }}</span>
</div>
</Card>
</template>
</div>
</template>