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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user