agregar modulo usuarios con AG Grid + tema shadcn + integracion KAPPA employees

This commit is contained in:
2026-05-26 08:02:50 -05:00
parent fbd128a19e
commit 04d9d6cabc
9 changed files with 413 additions and 0 deletions
+8
View File
@@ -12,6 +12,8 @@
"@tauri-apps/api": "^2.11.0", "@tauri-apps/api": "^2.11.0",
"@tauri-apps/cli": "^2.11.2", "@tauri-apps/cli": "^2.11.2",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",
"ag-grid-community": "^35.3.0",
"ag-grid-vue3": "^35.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.4", "dexie": "^4.0.4",
@@ -459,6 +461,12 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ag-charts-types": ["ag-charts-types@13.3.0", "", {}, "sha512-UMoAn908LC4ZIJSNfUckSBEFa79Mi1vFRA8qIRx+NusEuuFgXDioCZx4MxM7O3rDXlxTWH9DvQmcDjh7vyd89w=="],
"ag-grid-community": ["ag-grid-community@35.3.0", "", { "dependencies": { "ag-charts-types": "13.3.0" } }, "sha512-c9WQWB88J965IjBC/GPUX30aAZix10o6oYT86DWipcxgLZTIQlLSilJJEr1bno/245rPEAIMjhoU1gp9VIfURg=="],
"ag-grid-vue3": ["ag-grid-vue3@35.3.0", "", { "dependencies": { "ag-grid-community": "35.3.0" }, "peerDependencies": { "vue": "^3.5.32" } }, "sha512-7UnAt/xMosY3WX6Lg7t8YF6QyLkWQL/fvsYzY5hyPvBGtX84VrikfiO4zBesiJECkhj+AzfNlD2E89sBSVMTgQ=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
+2
View File
@@ -18,6 +18,8 @@
"@tauri-apps/api": "^2.11.0", "@tauri-apps/api": "^2.11.0",
"@tauri-apps/cli": "^2.11.2", "@tauri-apps/cli": "^2.11.2",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",
"ag-grid-community": "^35.3.0",
"ag-grid-vue3": "^35.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.4", "dexie": "^4.0.4",
+58
View File
@@ -0,0 +1,58 @@
/**
* AG Grid theme — shadcn-vue New York style.
* Hereda variables CSS de shadcn para light/dark automático.
*/
.ag-theme-alpha {
--ag-active-color: var(--primary);
--ag-background-color: var(--background);
--ag-border-color: var(--border);
--ag-header-background-color: var(--muted);
--ag-header-foreground-color: var(--muted-foreground);
--ag-header-column-separator-color: var(--border);
--ag-odd-row-background-color: transparent;
--ag-row-hover-color: var(--accent);
--ag-selected-row-background-color: color-mix(in oklch, var(--primary) 10%, transparent);
--ag-font-family: var(--font-sans);
--ag-font-size: 13px;
--ag-row-height: 40px;
--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-theme-alpha .ag-root-wrapper {
border-radius: var(--radius);
border: 1px solid var(--border);
}
.ag-theme-alpha .ag-header-cell {
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.ag-theme-alpha .ag-cell {
display: flex;
align-items: center;
}
+6
View File
@@ -9,6 +9,8 @@ import type {
KappaPlanningEntry, KappaPlanningEntry,
KappaBusinessRule, KappaBusinessRule,
KappaRequirement, KappaRequirement,
KappaEmployee,
PaginatedResponse,
} from '@/types/kappa' } from '@/types/kappa'
const BASE = '/api' const BASE = '/api'
@@ -92,6 +94,10 @@ class KappaAPI {
return this.request<unknown[]>('GET', '/users/all/') return this.request<unknown[]>('GET', '/users/all/')
} }
async getEmployees(page = 1): Promise<PaginatedResponse<KappaEmployee>> {
return this.request<PaginatedResponse<KappaEmployee>>('GET', `/employees/?page=${page}`)
}
async getUserStories(initiativeId?: number): Promise<KappaUserStory[]> { async getUserStories(initiativeId?: number): Promise<KappaUserStory[]> {
const path = initiativeId ? `/userstorys/?initiative=${initiativeId}` : '/userstorys/' const path = initiativeId ? `/userstorys/?initiative=${initiativeId}` : '/userstorys/'
return this.request<KappaUserStory[]>('GET', path) return this.request<KappaUserStory[]>('GET', path)
+111
View File
@@ -0,0 +1,111 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { kappa } from '@/services/kappa-api'
import type { KappaEmployee } from '@/types/kappa'
export interface AlphaUser {
id: number
email: string
first_name: string
last_name: string
full_name: string
is_active: boolean
cell?: string
role?: string
seniority?: string
projects: string[]
projects_count: number
employee_ref?: KappaEmployee
}
export const useUsersStore = defineStore('users', () => {
const users = ref<AlphaUser[]>([])
const employees = ref<KappaEmployee[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const totalPages = ref(1)
const currentPage = ref(1)
const activeUsers = computed(() => users.value.filter(u => u.is_active))
const devsByProject = computed(() => {
const map = new Map<string, AlphaUser[]>()
for (const u of users.value) {
for (const p of u.projects) {
if (!map.has(p)) map.set(p, [])
map.get(p)!.push(u)
}
}
return map
})
async function fetchAll() {
loading.value = true
error.value = null
try {
const [rawUsers, page1] = await Promise.all([
kappa.getUsers(),
kappa.getEmployees(1),
])
employees.value = page1.results
totalPages.value = Math.ceil(page1.count / 25)
if (totalPages.value > 1) {
const restPages = []
for (let p = 2; p <= totalPages.value; p++) {
restPages.push(kappa.getEmployees(p))
}
const rest = await Promise.all(restPages)
for (const r of rest) {
employees.value.push(...r.results)
}
}
users.value = buildUsers(rawUsers, employees.value)
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
function buildUsers(raw: unknown[], emps: KappaEmployee[]): AlphaUser[] {
return raw.map((u: any) => {
const userEmps = emps.filter(e => e.user === u.id)
return {
id: u.id,
email: u.email || '',
first_name: u.first_name || '',
last_name: u.last_name || '',
full_name: u.full_name || `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.email,
is_active: u.is_active !== undefined ? u.is_active : true,
projects: userEmps
.filter(e => e.initiative_name)
.map(e => e.initiative_name!),
projects_count: userEmps.filter(e => e.initiative).length,
employee_ref: userEmps[0] || undefined,
}
})
}
function updateLocal(id: number, data: Partial<AlphaUser>) {
const idx = users.value.findIndex(u => u.id === id)
if (idx !== -1) {
users.value[idx] = { ...users.value[idx], ...data }
}
}
return {
users,
employees,
loading,
error,
totalPages,
currentPage,
activeUsers,
devsByProject,
fetchAll,
updateLocal,
}
})
+4
View File
@@ -5,6 +5,10 @@
@import "tw-animate-css"; @import "tw-animate-css";
@import "ag-grid-community/styles/ag-grid.css";
@import "./assets/ag-grid-alpha.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
+15
View File
@@ -101,6 +101,21 @@ export interface KappaRequirement {
name_requirement?: string name_requirement?: string
} }
export interface KappaEmployee {
id: number
first_name?: string
last_name?: string
full_name?: string
email?: string
user?: number
initiative?: number | null
initiative_name?: string
initiative_key?: string
position?: string
is_active?: boolean
created_at?: string
}
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
count: number count: number
next: string | null next: string | null
+4
View File
@@ -6,6 +6,7 @@ import SiteHeader from "@/components/dashboard/SiteHeader.vue"
import SectionCards from "@/components/dashboard/SectionCards.vue" import SectionCards from "@/components/dashboard/SectionCards.vue"
import DashboardView from "@/views/DashboardView.vue" import DashboardView from "@/views/DashboardView.vue"
import ProjectListView from "@/views/ProjectListView.vue" import ProjectListView from "@/views/ProjectListView.vue"
import UsersView from "@/views/UsersView.vue"
const sidebarStyle = { const sidebarStyle = {
'--sidebar-width': '16rem', '--sidebar-width': '16rem',
@@ -149,6 +150,9 @@ const tabContent: Record<string, { title: string; description: string; cards: {
@select-project="openProject()" @select-project="openProject()"
/> />
</template> </template>
<template v-else-if="activeTab === 'team'">
<UsersView />
</template>
<template v-else> <template v-else>
<SectionCards :cards="tabContent[activeTab]?.cards ?? []" /> <SectionCards :cards="tabContent[activeTab]?.cards ?? []" />
</template> </template>
+205
View File
@@ -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>