users: AG Grid reemplazado por tabla nativa paginada + QA indicators

- UsersView: tabla nativa con busqueda, paginacion (15/page), iniciales
- UsersView: eliminada dependencia ag-grid (mantenida en bundle por otros modulos)
- DashboardView: QA metrics con desglose auto/parcial/manual
- DashboardView: seccion expandible con planes QA detallados
- qa-analyzer: fix field name automatizable
- ToDo: K-09 eliminada
This commit is contained in:
2026-05-28 23:49:41 -05:00
parent 8667cddc46
commit 4e90f6f7b2
3 changed files with 182 additions and 88 deletions
+2 -1
View File
@@ -4,10 +4,11 @@ import db from '@/services/db'
export interface QATestCase {
type: string
description: string
automatable: 'SÍ' | 'PARCIAL' | 'MANUAL'
automatizable: string
tool: string
}
export interface HUQAPlan {
huTitle: string
huId: string
+61 -3
View File
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { useProjectsStore } from '@/stores/projects'
import { useWorkItemsStore } from '@/stores/workitems'
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
import { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus, Brain, Sparkles, Loader2, CheckCircle2, XCircle, Send } from 'lucide-vue-next'
import { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus, Brain, Sparkles, Loader2, CheckCircle2, XCircle, Send, ChevronDown } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import HuDrafts from '@/components/HuDrafts.vue'
import AiProjectChat from '@/components/AiProjectChat.vue'
@@ -38,6 +38,22 @@ const qaPlans = ref<any[]>([])
const generatingQA = ref<string | null>(null)
const expandedQA = ref<string | null>(null)
const qaMetrics = computed(() => {
const plans = qaPlans.value
let auto = 0, manual = 0, parcial = 0, totalCases = 0
for (const p of plans) {
const plan = parseQAPlan(p)
if (!plan) continue
for (const tc of plan.testCases) {
totalCases++
if (tc.automatizable === 'SÍ') auto++
else if (tc.automatizable === 'MANUAL') manual++
else parcial++
}
}
return { total: plans.length, auto, manual, parcial, totalCases }
})
async function loadQAPlans(projectId: number) {
qaPlans.value = await getQAPlans(projectId)
}
@@ -58,6 +74,10 @@ function parseQAPlan(record: any): HUQAPlan | null {
try { return JSON.parse(record.plan) as HUQAPlan } catch { return null }
}
function toggleQAExpand(id: string) {
expandedQA.value = expandedQA.value === id ? null : id
}
function qaBadgeColor(a: string) {
if (a === 'SÍ') return 'text-green-600 border-green-300'
if (a === 'MANUAL') return 'text-red-600 border-red-300'
@@ -284,8 +304,13 @@ const statusLabel = (status: unknown) => {
<CheckCircle2 class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ qaPlans.length }}</div>
<p class="text-xs text-muted-foreground">Planes QA generados</p>
<div class="text-2xl font-bold">{{ qaMetrics.total }}</div>
<p class="text-xs text-muted-foreground">Planes QA · {{ qaMetrics.totalCases }} casos</p>
<div class="flex gap-2 mt-1.5 text-[10px]">
<span class="text-green-600 dark:text-green-400">{{ qaMetrics.auto }} auto</span>
<span class="text-amber-600 dark:text-amber-400">{{ qaMetrics.parcial }} parcial</span>
<span class="text-red-600 dark:text-red-400">{{ qaMetrics.manual }} manual</span>
</div>
</CardContent>
</Card>
@@ -441,6 +466,39 @@ const statusLabel = (status: unknown) => {
</div>
</template>
<!-- QA Plans -->
<Card v-if="qaPlans.length > 0" id="dashboard-qa-plans">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium flex items-center gap-2">
<CheckCircle2 class="size-4" />
Planes QA · {{ qaPlans.length }}
</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<div v-for="p in qaPlans" :key="p.id" class="border rounded-lg p-3 text-sm">
<div class="flex items-center justify-between cursor-pointer" @click="toggleQAExpand(p.id)">
<span class="font-medium">{{ p.huTitle }}</span>
<ChevronDown class="size-4 text-muted-foreground transition-transform" :class="expandedQA === p.id ? 'rotate-180' : ''" />
</div>
<div v-if="expandedQA === p.id" class="mt-2 space-y-2">
<template v-if="parseQAPlan(p) as HUQAPlan">
<div class="text-xs text-muted-foreground">{{ (parseQAPlan(p) as HUQAPlan).acceptanceCriteria?.length || 0 }} criterios, {{ (parseQAPlan(p) as HUQAPlan).testCases.length }} casos de prueba</div>
<table class="w-full text-xs">
<thead><tr class="text-muted-foreground"><th class="text-left py-1">Prueba</th><th class="text-left py-1">Tipo</th><th class="text-left py-1">Herramienta</th></tr></thead>
<tbody>
<tr v-for="(tc, i) in (parseQAPlan(p) as HUQAPlan).testCases" :key="i" class="border-t">
<td class="py-1 pr-2">{{ tc.description }}</td>
<td class="py-1 pr-2"><Badge variant="outline" :class="qaBadgeColor(tc.automatizable)" class="text-[10px]">{{ tc.automatizable }}</Badge></td>
<td class="py-1 text-muted-foreground">{{ tc.tool }}</td>
</tr>
</tbody>
</table>
</template>
</div>
</div>
</CardContent>
</Card>
<!-- Borradores (Tauri) -->
<HuDrafts v-if="project" :initiative-id="project.id" />
+118 -83
View File
@@ -1,13 +1,20 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef } from 'ag-grid-community'
import { ModuleRegistry, AllCommunityModule } from 'ag-grid-community'
import { useUsersStore, type AlphaUser } from '@/stores/users'
import { useUsersStore } from '@/stores/users'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
IconExclamationCircle,
IconUsers,
@@ -15,13 +22,13 @@ import {
IconClipboardText,
IconUserCog,
IconBuildingFactory,
IconChevronLeft,
IconChevronRight,
IconSearch,
} from '@tabler/icons-vue'
ModuleRegistry.registerModules([AllCommunityModule])
const { t } = useI18n()
const store = useUsersStore()
const gridReady = ref(false)
const roleIcons: Record<string, any> = {
DEV: IconCode,
@@ -51,10 +58,9 @@ const badgeColors: Record<string, string> = {
}
const teamRoles = ['DEV', 'BA', 'PM', 'PO', 'QA', 'TL']
const PAGE_SIZE = 15
const teamMembers = computed(() =>
store.users.filter(u => teamRoles.includes(u.role || ''))
)
const teamMembers = computed(() => store.users.filter(u => teamRoles.includes(u.role || '')))
const stats = computed(() => ({
total: store.users.length,
@@ -65,62 +71,37 @@ const stats = computed(() => ({
}, {} as Record<string, number>),
}))
const columnDefs = computed<ColDef[]>(() => [
{
field: 'full_name',
headerName: t('users.name'),
flex: 2,
minWidth: 180,
filter: true,
cellRenderer: (params: any) => {
const initials = `${params.data.first_name?.[0] || ''}${params.data.last_name?.[0] || ''}`
return `<div class="flex items-center gap-2 py-1">
<div class="flex items-center justify-center size-7 rounded-full bg-primary/10 text-primary text-xs font-semibold flex-shrink-0">${initials}</div>
<span class="font-medium">${params.value}</span>
</div>`
},
},
{ field: 'email', headerName: t('users.email'), flex: 2, minWidth: 200, filter: true },
{
field: 'role',
headerName: t('users.role'),
width: 90,
filter: true,
cellRenderer: (params: any) => {
if (!params.value) return '—'
const cls = badgeColors[params.value] || 'bg-muted text-muted-foreground'
return `<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${cls}">${params.value}</span>`
},
},
{ field: 'cell', headerName: t('users.cell'), width: 110, filter: true, cellRenderer: (p: any) => p.value || '—' },
{ field: 'seniority', headerName: t('users.seniority'), width: 100, filter: true, cellRenderer: (p: any) => p.value || '—' },
{
field: 'projects_count',
headerName: t('users.projects'),
width: 100,
filter: 'agNumberColumnFilter',
cellRenderer: (params: any) => {
const c = params.value || 0
if (c === 0) return '—'
return `<span class="font-semibold">${c}</span>`
},
},
{
field: 'projects',
headerName: t('users.assignments'),
flex: 3,
minWidth: 200,
cellRenderer: (params: any) => {
const p: string[] = params.value || []
if (!p.length) return '—'
return p.map((n: string) => `<span class="inline-flex rounded-full bg-muted px-2 py-0.5 text-xs mr-1">${n}</span>`).join('')
},
},
])
// ─── Native paginated table ─────────────────────────────
const searchQuery = ref('')
const currentPage = ref(1)
onMounted(() => {
store.fetchAll()
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)
)
})
const totalPages = computed(() => Math.ceil(filteredUsers.value.length / PAGE_SIZE))
const paginatedUsers = computed(() => {
const start = (currentPage.value - 1) * 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 }) {
return `${(u.first_name || '')[0] || ''}${(u.last_name || '')[0] || ''}`
}
onMounted(() => store.fetchAll())
</script>
<template>
@@ -253,27 +234,81 @@ onMounted(() => {
</div>
</div>
<!-- AG Grid table -->
<div>
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">
{{ t('users.allUsers') }}
</h3>
<div
id="users-table"
class="ag-theme-alpha-shadcn w-full rounded-lg border overflow-hidden"
:style="{ height: Math.max(300, Math.min(600, store.users.length * 45 + 45)) + 'px' }"
>
<AgGridVue
:row-data="store.users"
:column-defs="columnDefs"
:default-col-def="{ sortable: true, resizable: true }"
:pagination="true"
:pagination-page-size="15"
dom-layout="normal"
@grid-ready="gridReady = true"
/>
<!-- 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>
</CardHeader>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<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>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="user in paginatedUsers" :key="user.id">
<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>
<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
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>
</div>
</Card>
</template>
</div>
</template>