From 388fa09f3e9b386f121fe22e16e8ec04c728e1a2 Mon Sep 17 00:00:00 2001 From: Ricardo Gonzalez Date: Fri, 29 May 2026 01:30:50 -0500 Subject: [PATCH] cells: modulo de celulas + miembros + reemplazo de roles en UsersView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src-tauri/src/db.rs | 8 - src/i18n/locales/en.json | 2 + src/i18n/locales/es.json | 2 + src/services/cells-db.ts | 65 ++++++ src/services/db.ts | 21 +- src/services/users-db.ts | 1 + src/stores/users.ts | 42 +++- src/views/UsersView.vue | 452 ++++++++++++++++++--------------------- 8 files changed, 340 insertions(+), 253 deletions(-) create mode 100644 src/services/cells-db.ts diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 4ff950b..955fcfe 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -363,14 +363,6 @@ async fn get_conn(db_path: &str) -> Result { 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) } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5207623..b28b317 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ddc43e6..a70cce1 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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", diff --git a/src/services/cells-db.ts b/src/services/cells-db.ts new file mode 100644 index 0000000..57646f3 --- /dev/null +++ b/src/services/cells-db.ts @@ -0,0 +1,65 @@ +import db, { type CellRecord, type CellMemberRecord } from '@/services/db' +export type { CellRecord, CellMemberRecord } + +// ─── Cells ────────────────────────────────────────────── + +export async function getCells(): Promise { + return db.cells.toArray() +} + +export async function getCell(id: string): Promise { + 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 { + 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 { + 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() + 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 })) +} diff --git a/src/services/db.ts b/src/services/db.ts index 5dacf65..02da8f5 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -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 project_docs: Dexie.Table @@ -106,9 +121,11 @@ const db = new Dexie('alpha-core') as Dexie & { qa_plans: Dexie.Table users: Dexie.Table lookups: Dexie.Table + cells: Dexie.Table + cell_members: Dexie.Table } -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 diff --git a/src/services/users-db.ts b/src/services/users-db.ts index 66f7d47..c43412f 100644 --- a/src/services/users-db.ts +++ b/src/services/users-db.ts @@ -1,4 +1,5 @@ import db, { type UserRecord, type LookupRecord } from '@/services/db' +export type { UserRecord, LookupRecord } // ─── Users ─────────────────────────────────────────────── diff --git a/src/stores/users.ts b/src/stores/users.ts index 6018560..1a095e9 100644 --- a/src/stores/users.ts +++ b/src/stores/users.ts @@ -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 } diff --git a/src/views/UsersView.vue b/src/views/UsersView.vue index a338de3..ba0a39c 100644 --- a/src/views/UsersView.vue +++ b/src/views/UsersView.vue @@ -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 = { - DEV: IconCode, - BA: IconClipboardText, - PM: IconUserCog, - PO: IconBuildingFactory, - QA: IconCode, - TL: IconUserCog, +// ─── Cells ─────────────────────────────────────────────── +const cells = ref<(CellRecord & { memberCount: number })[]>([]) +const expandedCell = ref(null) +const cellMembers = ref>({}) +const showNewCell = ref(false) +const newCellName = ref('') +const newCellDesc = ref('') +const newCellType = ref<'normal' | 'transversal'>('normal') + +// Add member +const addingToCell = ref(null) +const selectedUserId = ref(null) + +async function loadCells() { + cells.value = await getAllCellsWithCounts() + for (const c of cells.value) { + loadMembers(c.id) + } } -const roleColors: Record = { - 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 = { - 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), -})) +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() +})