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; 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) Ok(conn)
} }
+2
View File
@@ -145,6 +145,8 @@
"teamMembers": "Team members · {count}", "teamMembers": "Team members · {count}",
"projectCount": "{count} project | {count} projects", "projectCount": "{count} project | {count} projects",
"allUsers": "All users", "allUsers": "All users",
"allRoles": "All roles",
"allProjects": "All projects",
"name": "Name", "name": "Name",
"email": "Email", "email": "Email",
"role": "Role", "role": "Role",
+2
View File
@@ -145,6 +145,8 @@
"teamMembers": "Miembros del equipo · {count}", "teamMembers": "Miembros del equipo · {count}",
"projectCount": "{count} proyecto | {count} proyectos", "projectCount": "{count} proyecto | {count} proyectos",
"allUsers": "Todos los usuarios", "allUsers": "Todos los usuarios",
"allRoles": "Todos los roles",
"allProjects": "Todos los proyectos",
"name": "Nombre", "name": "Nombre",
"email": "Email", "email": "Email",
"role": "Rol", "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 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 & { const db = new Dexie('alpha-core') as Dexie & {
settings: Dexie.Table<SettingEntry, string> settings: Dexie.Table<SettingEntry, string>
project_docs: Dexie.Table<ProjectDocRecord, number> project_docs: Dexie.Table<ProjectDocRecord, number>
@@ -106,9 +121,11 @@ const db = new Dexie('alpha-core') as Dexie & {
qa_plans: Dexie.Table<QAPlanRecord, string> qa_plans: Dexie.Table<QAPlanRecord, string>
users: Dexie.Table<UserRecord, number> users: Dexie.Table<UserRecord, number>
lookups: Dexie.Table<LookupRecord, string> 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', settings: '&key',
project_docs: '&projectId, projectName, updatedAt', project_docs: '&projectId, projectName, updatedAt',
sessions: '++id, projectId, date', sessions: '++id, projectId, date',
@@ -118,6 +135,8 @@ db.version(6).stores({
qa_plans: '&id, projectId', qa_plans: '&id, projectId',
users: '&id', users: '&id',
lookups: '&[type+id], type', lookups: '&[type+id], type',
cells: '&id',
cell_members: '[cellId+userId], cellId, userId',
}) })
export default db export default db
+1
View File
@@ -1,4 +1,5 @@
import db, { type UserRecord, type LookupRecord } from '@/services/db' import db, { type UserRecord, type LookupRecord } from '@/services/db'
export type { UserRecord, LookupRecord }
// ─── Users ─────────────────────────────────────────────── // ─── Users ───────────────────────────────────────────────
+38 -4
View File
@@ -3,7 +3,7 @@ import { ref, computed } from 'vue'
import { kappa } from '@/services/kappa-api' import { kappa } from '@/services/kappa-api'
import { tauriDb, type AlphaUserRecord, type CellRecord } from '@/services/tauri-db' import { tauriDb, type AlphaUserRecord, type CellRecord } from '@/services/tauri-db'
import { syncUserDetails } from '@/services/user-sync' 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' import type { KappaEmployee } from '@/types/kappa'
export interface AlphaUser { export interface AlphaUser {
@@ -48,8 +48,34 @@ export const useUsersStore = defineStore('users', () => {
}) })
async function fetchAll() { async function fetchAll() {
loading.value = true
error.value = null 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 { try {
const [rawUsers, page1, localUsers, localCells] = await Promise.all([ const [rawUsers, page1, localUsers, localCells] = await Promise.all([
kappa.getUsers(), kappa.getUsers(),
@@ -76,10 +102,18 @@ export const useUsersStore = defineStore('users', () => {
const localMap = new Map(localUsers.map(u => [u.id, u])) const localMap = new Map(localUsers.map(u => [u.id, u]))
users.value = buildUsers(rawUsers, employees.value, localMap) 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) syncUsersToTurso(users.value)
} catch (e: any) { } catch (e: any) {
error.value = e.message if (!dbUsers.length) error.value = e.message
} finally { } finally {
loading.value = false loading.value = false
} }
+212 -240
View File
@@ -2,11 +2,19 @@
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { import {
Table, Table,
TableBody, TableBody,
@@ -18,72 +26,101 @@ import {
import { import {
IconExclamationCircle, IconExclamationCircle,
IconUsers, IconUsers,
IconCode, IconPlus,
IconClipboardText, IconTrash,
IconUserCog, IconUserPlus,
IconUserMinus,
IconBuildingFactory, IconBuildingFactory,
IconChevronLeft,
IconChevronRight,
IconSearch,
IconRefresh,
} from '@tabler/icons-vue' } from '@tabler/icons-vue'
const { t } = useI18n() const { t } = useI18n()
const store = useUsersStore() const store = useUsersStore()
const roleIcons: Record<string, any> = { // ─── Cells ───────────────────────────────────────────────
DEV: IconCode, const cells = ref<(CellRecord & { memberCount: number })[]>([])
BA: IconClipboardText, const expandedCell = ref<string | null>(null)
PM: IconUserCog, const cellMembers = ref<Record<string, any[]>>({})
PO: IconBuildingFactory, const showNewCell = ref(false)
QA: IconCode, const newCellName = ref('')
TL: IconUserCog, 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> = { async function loadMembers(cellId: string) {
DEV: 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950', cellMembers.value[cellId] = await getMembers(cellId)
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',
} }
const badgeColors: Record<string, string> = { function toggleCell(cellId: string) {
DEV: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', if (expandedCell.value === cellId) {
BA: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', expandedCell.value = null
PM: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', } else {
PO: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400', expandedCell.value = cellId
QA: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', loadMembers(cellId)
TL: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', }
} }
const teamRoles = ['DEV', 'BA', 'PM', 'PO', 'QA', 'TL'] async function createCell() {
const PAGE_SIZE = 15 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(() => ({ async function addUserToCell(cellId: string) {
total: store.users.length, if (!selectedUserId.value) return
active: store.activeUsers.length, await addMember({ cellId, userId: selectedUserId.value, roleInCell: 'dev', addedAt: new Date().toISOString() })
byRole: teamRoles.reduce((acc, role) => { selectedUserId.value = null
acc[role] = store.users.filter(u => u.role === role).length addingToCell.value = null
return acc await loadMembers(cellId)
}, {} as Record<string, number>), 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 searchQuery = ref('')
const currentPage = ref(1) const currentPage = ref(1)
const PAGE_SIZE = 15
const filteredUsers = computed(() => { const filteredUsers = computed(() => {
const q = searchQuery.value.toLowerCase().trim() const q = searchQuery.value.toLowerCase().trim()
if (!q) return store.users if (!q) return store.users
return store.users.filter(u => return store.users.filter(u =>
u.full_name?.toLowerCase().includes(q) || u.full_name?.toLowerCase().includes(q) ||
u.email?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q)
u.role?.toLowerCase().includes(q) ||
u.cell?.toLowerCase().includes(q)
) )
}) })
@@ -94,15 +131,14 @@ const paginatedUsers = computed(() => {
return filteredUsers.value.slice(start, start + PAGE_SIZE) 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 }) { function initials(u: { first_name?: string; last_name?: string }) {
return `${(u.first_name || '')[0] || ''}${(u.last_name || '')[0] || ''}` return `${(u.first_name || '')[0] || ''}${(u.last_name || '')[0] || ''}`
} }
onMounted(() => store.fetchAll()) onMounted(() => {
store.fetchAll()
loadCells()
})
</script> </script>
<template> <template>
@@ -111,216 +147,152 @@ onMounted(() => store.fetchAll())
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-2xl font-bold tracking-tight">{{ t('users.teamTitle') }}</h2> <h2 class="text-2xl font-bold tracking-tight">{{ t('users.teamTitle') }}</h2>
<p class="text-muted-foreground"> <p class="text-sm text-muted-foreground">{{ store.users.length }} usuarios · {{ cells.length }} células</p>
{{ t('users.teamSubtitle', { users: store.users.length, emps: store.employees.length }) }}
</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>
<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>
<Button size="sm" @click="showNewCell = !showNewCell">
<IconPlus class="size-4 mr-1" />
Nueva célula
</Button>
</div> </div>
<!-- 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>
</CardContent>
</Card>
<!-- Loading --> <!-- Loading -->
<template v-if="store.loading"> <template v-if="store.loading && cells.length === 0">
<div class="grid grid-cols-1 gap-4 @xl:grid-cols-2 @3xl:grid-cols-4"> <div class="grid grid-cols-1 gap-3 @xl:grid-cols-2 @3xl:grid-cols-3">
<Skeleton v-for="i in 4" :key="i" class="h-24 rounded-xl" /> <Skeleton v-for="i in 3" :key="i" class="h-28 rounded-xl" />
</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" />
</div> </div>
</template> </template>
<!-- Error --> <!-- Error -->
<template v-else-if="store.error"> <template v-else-if="store.error && cells.length === 0">
<div class="flex flex-col items-center gap-3 py-12 text-center"> <div class="flex flex-col items-center gap-3 py-12">
<IconExclamationCircle class="size-10 text-destructive" /> <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> <p class="text-sm text-muted-foreground">{{ store.error }}</p>
<button <Button variant="outline" @click="store.fetchAll()">{{ t('common.retry') }}</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>
</div> </div>
</template> </template>
<!-- Empty --> <!-- Cell cards -->
<template v-else-if="store.users.length === 0"> <div v-if="cells.length > 0" class="grid grid-cols-1 gap-3 @xl:grid-cols-2 @3xl:grid-cols-3">
<div class="flex flex-col items-center gap-3 py-12 text-center"> <Card
<IconUsers class="size-10 text-muted-foreground" /> v-for="c in cells"
<p class="text-lg font-medium">{{ t('users.emptyTitle') }}</p> :key="c.id"
<p class="text-sm text-muted-foreground">{{ t('users.emptyDescription') }}</p> class="cursor-pointer transition-colors hover:border-primary/40"
</div> :class="expandedCell === c.id ? 'border-primary' : ''"
</template> @click="toggleCell(c.id)"
>
<!-- Content --> <CardHeader class="p-4 pb-2 flex flex-row items-start justify-between">
<template v-else> <div class="space-y-1">
<!-- Stats --> <CardTitle class="text-sm font-medium flex items-center gap-2">
<div id="users-stats" class="grid grid-cols-2 gap-3 @xl:grid-cols-3 @3xl:grid-cols-6"> <IconBuildingFactory v-if="c.type === 'transversal'" class="size-4 text-amber-500" />
<Card v-for="role in teamRoles" :key="role" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card"> <IconUsers v-else class="size-4 text-muted-foreground" />
<CardHeader class="p-4 pb-2"> {{ c.name }}
<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> </CardTitle>
</CardHeader> <p v-if="c.description" class="text-xs text-muted-foreground">{{ c.description }}</p>
<CardContent class="p-4 pt-0"> </div>
<p class="text-2xl font-bold">{{ stats.byRole[role] || 0 }}</p> <div class="flex items-center gap-2 shrink-0">
</CardContent> <Badge variant="outline" class="text-[10px]" :class="c.type === 'transversal' ? 'border-amber-300 text-amber-600' : ''">
</Card> {{ c.type === 'transversal' ? 'Transversal' : 'Normal' }}
</div> </Badge>
<span class="text-xs text-muted-foreground">{{ c.memberCount }}</span>
<!-- 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">
<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"
>
<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] || '' }}
</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 }}
</Badge>
</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>
</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>
</CardContent>
</Card>
</div>
</div>
<!-- Native 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" />
<Input v-model="searchQuery" placeholder="Buscar..." class="pl-8 h-8 text-sm" @input="currentPage = 1" />
</div> </div>
</CardHeader> </CardHeader>
<CardContent class="p-0"> <!-- Expanded members -->
<Table> <CardContent v-if="expandedCell === c.id" class="p-4 pt-2 space-y-2" @click.stop>
<TableHeader> <div v-if="cellMembers[c.id]?.length">
<TableRow> <div v-for="m in cellMembers[c.id]" :key="m.userId" class="flex items-center justify-between py-1 text-sm">
<TableHead class="w-[60px]">ID</TableHead> <span>{{ userName(m.userId) }}</span>
<TableHead>{{ t('users.name') }}</TableHead> <Badge variant="outline" class="text-[10px]">{{ m.roleInCell }}</Badge>
<TableHead>{{ t('users.email') }}</TableHead> <Button variant="ghost" size="sm" class="size-6" @click="removeUserFromCell(c.id, m.userId)">
<TableHead class="w-[80px]">{{ t('users.role') }}</TableHead> <IconUserMinus class="size-3" />
<TableHead class="w-[100px]">{{ t('users.cell') }}</TableHead> </Button>
<TableHead class="w-[90px]">{{ t('users.seniority') }}</TableHead> </div>
<TableHead class="w-[70px] text-right">{{ t('users.projects') }}</TableHead> </div>
<TableHead>{{ t('users.assignments') }}</TableHead> <p v-else class="text-xs text-muted-foreground italic">Sin miembros</p>
</TableRow> <div v-if="addingToCell === c.id" class="flex gap-2">
</TableHeader> <Select v-model="selectedUserId">
<TableBody> <SelectTrigger class="flex-1 h-8 text-xs"><SelectValue placeholder="Seleccionar usuario..." /></SelectTrigger>
<TableRow v-for="user in paginatedUsers" :key="user.id"> <SelectContent>
<TableCell class="text-xs font-mono text-muted-foreground">{{ user.id }}</TableCell> <SelectItem v-for="u in store.users" :key="u.id" :value="u.id" class="text-xs">{{ u.full_name }}</SelectItem>
<TableCell> </SelectContent>
<div class="flex items-center gap-2"> </Select>
<div class="flex items-center justify-center size-7 rounded-full bg-primary/10 text-primary text-xs font-semibold shrink-0"> <Button size="sm" class="h-8 text-xs" :disabled="!selectedUserId" @click="addUserToCell(c.id)">Añadir</Button>
{{ initials(user) }} </div>
</div> <div class="flex gap-1">
<span class="font-medium text-sm">{{ user.full_name }}</span> <Button v-if="addingToCell !== c.id" size="sm" variant="outline" class="h-7 text-xs" @click="addingToCell = c.id">
</div> <IconUserPlus class="size-3 mr-1" /> Añadir miembro
</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>
</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>
<Button <Button size="sm" variant="ghost" class="h-7 text-xs text-destructive" @click="removeCell(c.id)">
v-for="p in Math.min(totalPages, 5)" <IconTrash class="size-3 mr-1" /> Eliminar
: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> </Button>
</div> </div>
</div> </CardContent>
</Card> </Card>
</template> </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>
<!-- Users table -->
<Card>
<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>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[60px]">ID</TableHead>
<TableHead>{{ t('users.name') }}</TableHead>
<TableHead>{{ t('users.email') }}</TableHead>
<TableHead>Células</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="user in paginatedUsers" :key="user.id">
<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>
<span class="font-medium text-sm">{{ user.full_name }}</span>
</div>
</TableCell>
<TableCell class="text-sm text-muted-foreground">{{ user.email }}</TableCell>
<TableCell>
<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>
<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>
</Card>
</div> </div>
</template> </template>