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 {
|
export interface QATestCase {
|
||||||
type: string
|
type: string
|
||||||
description: string
|
description: string
|
||||||
automatable: 'SÍ' | 'PARCIAL' | 'MANUAL'
|
automatizable: string
|
||||||
tool: string
|
tool: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface HUQAPlan {
|
export interface HUQAPlan {
|
||||||
huTitle: string
|
huTitle: string
|
||||||
huId: string
|
huId: string
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import { useWorkItemsStore } from '@/stores/workitems'
|
import { useWorkItemsStore } from '@/stores/workitems'
|
||||||
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
|
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 { Button } from '@/components/ui/button'
|
||||||
import HuDrafts from '@/components/HuDrafts.vue'
|
import HuDrafts from '@/components/HuDrafts.vue'
|
||||||
import AiProjectChat from '@/components/AiProjectChat.vue'
|
import AiProjectChat from '@/components/AiProjectChat.vue'
|
||||||
@@ -38,6 +38,22 @@ const qaPlans = ref<any[]>([])
|
|||||||
const generatingQA = ref<string | null>(null)
|
const generatingQA = ref<string | null>(null)
|
||||||
const expandedQA = 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) {
|
async function loadQAPlans(projectId: number) {
|
||||||
qaPlans.value = await getQAPlans(projectId)
|
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 }
|
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) {
|
function qaBadgeColor(a: string) {
|
||||||
if (a === 'SÍ') return 'text-green-600 border-green-300'
|
if (a === 'SÍ') return 'text-green-600 border-green-300'
|
||||||
if (a === 'MANUAL') return 'text-red-600 border-red-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" />
|
<CheckCircle2 class="size-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-2xl font-bold">{{ qaPlans.length }}</div>
|
<div class="text-2xl font-bold">{{ qaMetrics.total }}</div>
|
||||||
<p class="text-xs text-muted-foreground">Planes QA generados</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -441,6 +466,39 @@ const statusLabel = (status: unknown) => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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) -->
|
<!-- Borradores (Tauri) -->
|
||||||
<HuDrafts v-if="project" :initiative-id="project.id" />
|
<HuDrafts v-if="project" :initiative-id="project.id" />
|
||||||
|
|
||||||
|
|||||||
+119
-84
@@ -1,13 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { useUsersStore } from '@/stores/users'
|
||||||
import type { ColDef } from 'ag-grid-community'
|
|
||||||
import { ModuleRegistry, AllCommunityModule } from 'ag-grid-community'
|
|
||||||
import { useUsersStore, type AlphaUser } from '@/stores/users'
|
|
||||||
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 { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
import {
|
import {
|
||||||
IconExclamationCircle,
|
IconExclamationCircle,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
@@ -15,13 +22,13 @@ import {
|
|||||||
IconClipboardText,
|
IconClipboardText,
|
||||||
IconUserCog,
|
IconUserCog,
|
||||||
IconBuildingFactory,
|
IconBuildingFactory,
|
||||||
|
IconChevronLeft,
|
||||||
|
IconChevronRight,
|
||||||
|
IconSearch,
|
||||||
} from '@tabler/icons-vue'
|
} from '@tabler/icons-vue'
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule])
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const store = useUsersStore()
|
const store = useUsersStore()
|
||||||
const gridReady = ref(false)
|
|
||||||
|
|
||||||
const roleIcons: Record<string, any> = {
|
const roleIcons: Record<string, any> = {
|
||||||
DEV: IconCode,
|
DEV: IconCode,
|
||||||
@@ -51,10 +58,9 @@ const badgeColors: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const teamRoles = ['DEV', 'BA', 'PM', 'PO', 'QA', 'TL']
|
const teamRoles = ['DEV', 'BA', 'PM', 'PO', 'QA', 'TL']
|
||||||
|
const PAGE_SIZE = 15
|
||||||
|
|
||||||
const teamMembers = computed(() =>
|
const teamMembers = computed(() => store.users.filter(u => teamRoles.includes(u.role || '')))
|
||||||
store.users.filter(u => teamRoles.includes(u.role || ''))
|
|
||||||
)
|
|
||||||
|
|
||||||
const stats = computed(() => ({
|
const stats = computed(() => ({
|
||||||
total: store.users.length,
|
total: store.users.length,
|
||||||
@@ -65,62 +71,37 @@ const stats = computed(() => ({
|
|||||||
}, {} as Record<string, number>),
|
}, {} as Record<string, number>),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const columnDefs = computed<ColDef[]>(() => [
|
// ─── Native paginated table ─────────────────────────────
|
||||||
{
|
const searchQuery = ref('')
|
||||||
field: 'full_name',
|
const currentPage = ref(1)
|
||||||
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('')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
onMounted(() => {
|
const filteredUsers = computed(() => {
|
||||||
store.fetchAll()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -253,27 +234,81 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AG Grid table -->
|
<!-- Native table -->
|
||||||
<div>
|
<Card>
|
||||||
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">
|
<CardHeader class="pb-3 flex flex-row items-center justify-between gap-4">
|
||||||
{{ t('users.allUsers') }}
|
<CardTitle class="text-sm font-medium">{{ t('users.allUsers') }}</CardTitle>
|
||||||
</h3>
|
<div class="relative w-60">
|
||||||
<div
|
<IconSearch class="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
id="users-table"
|
<Input v-model="searchQuery" placeholder="Buscar..." class="pl-8 h-8 text-sm" @input="currentPage = 1" />
|
||||||
class="ag-theme-alpha-shadcn w-full rounded-lg border overflow-hidden"
|
</div>
|
||||||
:style="{ height: Math.max(300, Math.min(600, store.users.length * 45 + 45)) + 'px' }"
|
</CardHeader>
|
||||||
>
|
<CardContent class="p-0">
|
||||||
<AgGridVue
|
<Table>
|
||||||
:row-data="store.users"
|
<TableHeader>
|
||||||
:column-defs="columnDefs"
|
<TableRow>
|
||||||
:default-col-def="{ sortable: true, resizable: true }"
|
<TableHead>{{ t('users.name') }}</TableHead>
|
||||||
:pagination="true"
|
<TableHead>{{ t('users.email') }}</TableHead>
|
||||||
:pagination-page-size="15"
|
<TableHead class="w-[80px]">{{ t('users.role') }}</TableHead>
|
||||||
dom-layout="normal"
|
<TableHead class="w-[100px]">{{ t('users.cell') }}</TableHead>
|
||||||
@grid-ready="gridReady = true"
|
<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>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user