agregar modulo usuarios con AG Grid + tema shadcn + integracion KAPPA employees
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
|
||||
import { ModuleRegistry, AllCommunityModule } from 'ag-grid-community'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { IconExclamationCircle, IconUsers } from '@tabler/icons-vue'
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule])
|
||||
|
||||
const store = useUsersStore()
|
||||
const gridApi = ref<GridApi | null>(null)
|
||||
|
||||
const columnDefs: ColDef[] = [
|
||||
{
|
||||
field: 'full_name',
|
||||
headerName: 'Nombre',
|
||||
flex: 2,
|
||||
minWidth: 180,
|
||||
sort: 'asc',
|
||||
filter: 'agTextColumnFilter',
|
||||
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>
|
||||
<span class="font-medium">${params.value}</span>
|
||||
</div>`
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
headerName: 'Email',
|
||||
flex: 2,
|
||||
minWidth: 200,
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{
|
||||
field: 'role',
|
||||
headerName: 'Rol',
|
||||
flex: 1,
|
||||
minWidth: 100,
|
||||
filter: 'agTextColumnFilter',
|
||||
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>`
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'projects_count',
|
||||
headerName: 'Proyectos',
|
||||
flex: 1,
|
||||
minWidth: 110,
|
||||
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>`
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'projects',
|
||||
headerName: 'Proyectos asignados',
|
||||
flex: 3,
|
||||
minWidth: 220,
|
||||
filter: 'agTextColumnFilter',
|
||||
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>`
|
||||
},
|
||||
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 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
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" class="text-sm">
|
||||
{{ store.activeUsers.length }} 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" />
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="store.error" 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>
|
||||
<button
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
@click="store.fetchAll()"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="store.users.length === 0" 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>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div v-else class="ag-theme-alpha w-full" style="height: calc(100vh - 14rem)">
|
||||
<AgGridVue
|
||||
:row-data="store.users"
|
||||
:column-defs="columnDefs"
|
||||
:default-col-def="defaultColDef"
|
||||
:pagination="true"
|
||||
:pagination-page-size="20"
|
||||
:pagination-page-size-selector="[10, 20, 50, 100]"
|
||||
:animate-rows="false"
|
||||
row-selection="single"
|
||||
dom-layout="normal"
|
||||
@grid-ready="onGridReady"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
Reference in New Issue
Block a user