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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,4 +1,5 @@
|
||||
import db, { type UserRecord, type LookupRecord } from '@/services/db'
|
||||
export type { UserRecord, LookupRecord }
|
||||
|
||||
// ─── Users ───────────────────────────────────────────────
|
||||
|
||||
|
||||
+38
-4
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user