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;
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
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,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
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user