redisenar vista de equipo: tarjetas de miembros + AG Grid con tema shadcn corregido

This commit is contained in:
2026-05-26 14:26:21 -05:00
parent 04d9d6cabc
commit 122ab19a1d
4 changed files with 238 additions and 178 deletions
+26 -35
View File
@@ -1,58 +1,49 @@
/**
* AG Grid theme — shadcn-vue New York style.
* Hereda variables CSS de shadcn para light/dark automático.
* AG Grid theme — adaptado a shadcn-vue New York style.
* Usa las mismas CSS custom properties de shadcn para heredar light/dark.
*/
.ag-theme-alpha {
.ag-theme-alpha-shadcn {
--ag-active-color: var(--primary);
--ag-background-color: var(--background);
--ag-border-color: var(--border);
--ag-header-background-color: var(--muted);
--ag-foreground-color: var(--foreground);
--ag-background-color: var(--card);
--ag-secondary-foreground-color: var(--muted-foreground);
--ag-header-foreground-color: var(--muted-foreground);
--ag-header-background-color: var(--muted);
--ag-disabled-foreground-color: var(--muted-foreground);
--ag-border-color: var(--border);
--ag-header-column-separator-color: var(--border);
--ag-odd-row-background-color: transparent;
--ag-header-column-resize-handle-color: var(--border);
--ag-input-focus-border-color: var(--ring);
--ag-input-border-color: var(--border);
--ag-row-hover-color: var(--accent);
--ag-selected-row-background-color: color-mix(in oklch, var(--primary) 10%, transparent);
--ag-selected-row-background-color: color-mix(in oklch, var(--primary) 8%, var(--card));
--ag-range-selection-border-color: var(--primary);
--ag-font-family: var(--font-sans);
--ag-font-size: 13px;
--ag-row-height: 40px;
--ag-row-height: 44px;
--ag-header-height: 40px;
--ag-range-selection-border-color: var(--primary);
--ag-input-focus-border-color: var(--ring);
--ag-foreground-color: var(--foreground);
--ag-secondary-foreground-color: var(--muted-foreground);
--ag-disabled-foreground-color: var(--muted-foreground);
--ag-header-column-resize-handle-color: var(--border);
--ag-checkbox-checked-color: var(--primary);
--ag-checkbox-unchecked-color: var(--muted-foreground);
--ag-checkbox-indeterminate-color: var(--primary);
--ag-input-border-color: var(--border);
--ag-icon-font-color: var(--muted-foreground);
--ag-borders: solid 1px;
--ag-borders-critical: solid 1px;
--ag-borders-secondary: solid 1px;
--ag-borders-row: none;
--ag-borders-input: solid 1px;
--ag-cell-horizontal-padding: 12px;
--ag-card-radius: var(--radius);
--ag-card-shadow: var(--shadow-sm);
--ag-wrapper-border-radius: var(--radius);
--ag-pagination-background-color: transparent;
--ag-borders-row: solid 1px;
--ag-cell-horizontal-padding: 16px;
--ag-card-radius: var(--radius-lg);
--ag-wrapper-border-radius: var(--radius-lg);
}
.ag-theme-alpha .ag-root-wrapper {
border-radius: var(--radius);
.ag-theme-alpha-shadcn .ag-root-wrapper {
border-radius: var(--radius-lg);
border: 1px solid var(--border);
font-family: var(--font-sans);
}
.ag-theme-alpha .ag-header-cell {
.ag-theme-alpha-shadcn .ag-header-cell {
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.025em;
}
.ag-theme-alpha .ag-cell {
display: flex;
align-items: center;
.ag-theme-alpha-shadcn .ag-row {
border-color: var(--border);
}
+2
View File
@@ -2,6 +2,8 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { i18n } from './i18n'
import 'ag-grid-community/styles/ag-grid.css'
import './assets/ag-grid-alpha.css'
import './style.css'
const app = createApp(App)
-4
View File
@@ -5,10 +5,6 @@
@import "tw-animate-css";
@import "ag-grid-community/styles/ag-grid.css";
@import "./assets/ag-grid-alpha.css";
@custom-variant dark (&:is(.dark *));
:root {
+189 -118
View File
@@ -1,17 +1,67 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
import type { ColDef } from 'ag-grid-community'
import { ModuleRegistry, AllCommunityModule } from 'ag-grid-community'
import { useUsersStore } from '@/stores/users'
import { useUsersStore, type AlphaUser } from '@/stores/users'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { IconExclamationCircle, IconUsers } from '@tabler/icons-vue'
import {
IconExclamationCircle,
IconUsers,
IconCode,
IconClipboardText,
IconUserCog,
IconBuildingFactory,
} from '@tabler/icons-vue'
ModuleRegistry.registerModules([AllCommunityModule])
const store = useUsersStore()
const gridApi = ref<GridApi | null>(null)
const gridReady = ref(false)
const roleIcons: Record<string, any> = {
DEV: IconCode,
BA: IconClipboardText,
PM: IconUserCog,
PO: IconBuildingFactory,
QA: IconCode,
TL: IconUserCog,
}
const roleColors: Record<string, string> = {
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',
}
const badgeColors: Record<string, string> = {
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',
}
const teamRoles = ['DEV', 'BA', 'PM', 'PO', 'QA', 'TL']
const teamMembers = computed(() =>
store.users.filter(u => teamRoles.includes(u.role || ''))
)
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<string, number>),
}))
const columnDefs: ColDef[] = [
{
@@ -19,140 +69,86 @@ const columnDefs: ColDef[] = [
headerName: 'Nombre',
flex: 2,
minWidth: 180,
sort: 'asc',
filter: 'agTextColumnFilter',
filter: true,
cellRenderer: (params: any) => {
return `<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">
${params.data.first_name?.[0] || ''}${params.data.last_name?.[0] || ''}
</div>
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: 'Email',
flex: 2,
minWidth: 200,
filter: 'agTextColumnFilter',
},
{ field: 'email', headerName: 'Email', flex: 2, minWidth: 200, filter: true },
{
field: 'role',
headerName: 'Rol',
flex: 1,
minWidth: 100,
filter: 'agTextColumnFilter',
width: 90,
filter: true,
cellRenderer: (params: any) => {
if (!params.value) return `<span class="text-muted-foreground text-xs">—</span>`
const colors: Record<string, string> = {
BA: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
DEV: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
PM: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
QA: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
PO: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400',
TL: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
}
const cls = colors[params.value] || 'bg-muted text-muted-foreground'
return `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}">${params.value}</span>`
},
},
{
field: 'cell',
headerName: 'Célula',
flex: 1,
minWidth: 100,
filter: 'agTextColumnFilter',
cellRenderer: (params: any) => {
if (!params.value) return `<span class="text-muted-foreground text-xs">—</span>`
return `<span class="inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium">${params.value}</span>`
},
},
{
field: 'seniority',
headerName: 'Seniority',
flex: 1,
minWidth: 100,
filter: 'agTextColumnFilter',
cellRenderer: (params: any) => {
if (!params.value) return `<span class="text-muted-foreground text-xs">—</span>`
const map: Record<string, string> = {
jr: 'Jr',
mid: 'Mid',
sr: 'Sr',
lead: 'Lead',
}
return `<span class="text-sm">${map[params.value] || params.value}</span>`
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: 'Célula', width: 110, filter: true, cellRenderer: (p: any) => p.value || '—' },
{ field: 'seniority', headerName: 'Seniority', width: 100, filter: true, cellRenderer: (p: any) => p.value || '—' },
{
field: 'projects_count',
headerName: 'Proyectos',
flex: 1,
minWidth: 110,
width: 100,
filter: 'agNumberColumnFilter',
cellRenderer: (params: any) => {
const count = params.value || 0
if (count === 0) return `<span class="text-muted-foreground text-xs">—</span>`
const cls = count >= 5 ? 'text-destructive' : count >= 3 ? 'text-amber-500' : 'text-emerald-500'
return `<span class="font-semibold text-sm ${cls}">${count}</span>`
const c = params.value || 0
if (c === 0) return '—'
return `<span class="font-semibold">${c}</span>`
},
},
{
field: 'projects',
headerName: 'Proyectos asignados',
headerName: 'Asignaciones',
flex: 3,
minWidth: 220,
filter: 'agTextColumnFilter',
minWidth: 200,
cellRenderer: (params: any) => {
const projects: string[] = params.value || []
if (projects.length === 0) return `<span class="text-muted-foreground text-xs">Sin asignación</span>`
const badges = projects.map((p: string) =>
`<span class="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs mr-1 mb-1">${p}</span>`
).join('')
return `<div class="flex flex-wrap gap-1 py-0.5">${badges}</div>`
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('')
},
autoHeight: true,
},
]
const defaultColDef: ColDef = {
sortable: true,
resizable: true,
wrapText: true,
}
function onGridReady(params: GridReadyEvent) {
gridApi.value = params.api
params.api.sizeColumnsToFit()
}
onMounted(() => {
store.fetchAll()
})
</script>
<template>
<div class="flex flex-col gap-4 px-4 lg:px-6">
<div class="flex flex-col gap-6 px-4 lg:px-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">Equipo</h2>
<p class="text-muted-foreground">
{{ store.users.length }} miembros · {{ store.employees.length }} asignaciones
{{ store.users.length }} miembros · {{ store.employees.length }} asignaciones en KAPPA
</p>
</div>
<Badge variant="outline" class="text-sm">
{{ store.activeUsers.length }} activos
{{ stats.active }} activos
</Badge>
</div>
<!-- Loading -->
<div v-if="store.loading" class="space-y-2">
<Skeleton v-for="i in 5" :key="i" class="h-10 w-full" />
<template v-if="store.loading">
<div class="grid grid-cols-1 gap-4 @xl:grid-cols-2 @3xl:grid-cols-4">
<Skeleton v-for="i in 4" :key="i" class="h-24 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>
</template>
<!-- Error -->
<div v-else-if="store.error" class="flex flex-col items-center gap-3 py-12 text-center">
<template v-else-if="store.error">
<div class="flex flex-col items-center gap-3 py-12 text-center">
<IconExclamationCircle class="size-10 text-destructive" />
<p class="text-lg font-medium">Error al cargar usuarios</p>
<p class="text-sm text-muted-foreground">{{ store.error }}</p>
@@ -163,43 +159,118 @@ onMounted(() => {
Reintentar
</button>
</div>
</template>
<!-- Empty -->
<div v-else-if="store.users.length === 0" class="flex flex-col items-center gap-3 py-12 text-center">
<template v-else-if="store.users.length === 0">
<div class="flex flex-col items-center gap-3 py-12 text-center">
<IconUsers class="size-10 text-muted-foreground" />
<p class="text-lg font-medium">Sin usuarios</p>
<p class="text-sm text-muted-foreground">
No se encontraron usuarios en KAPPA.
</p>
<p class="text-sm text-muted-foreground">No se encontraron usuarios en KAPPA.</p>
</div>
</template>
<!-- Content -->
<template v-else>
<!-- Stats -->
<div class="grid grid-cols-2 gap-3 @xl:grid-cols-3 @3xl:grid-cols-6">
<Card v-for="role in teamRoles" :key="role" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
<CardHeader class="p-4 pb-2">
<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>
</CardHeader>
<CardContent class="p-4 pt-0">
<p class="text-2xl font-bold">{{ stats.byRole[role] || 0 }}</p>
</CardContent>
</Card>
</div>
<!-- Grid -->
<div v-else class="ag-theme-alpha w-full" style="height: calc(100vh - 14rem)">
<!-- Team Member Cards -->
<div>
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">
Miembros del equipo · {{ teamMembers.length }}
</h3>
<div 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">{{ user.projects_count }} proyecto{{ user.projects_count !== 1 ? 's' : '' }}</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>
<!-- AG Grid table -->
<div>
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">
Todos los usuarios
</h3>
<div
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="defaultColDef"
:default-col-def="{ sortable: true, resizable: true }"
:pagination="true"
:pagination-page-size="20"
:pagination-page-size-selector="[10, 20, 50, 100]"
:animate-rows="false"
row-selection="single"
:pagination-page-size="15"
dom-layout="normal"
@grid-ready="onGridReady"
@grid-ready="gridReady = true"
/>
</div>
</div>
</template>
</div>
</template>
<style scoped>
:deep(.ag-cell) {
display: flex;
align-items: center;
}
:deep(.ag-header-cell) {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted-foreground);
}
</style>