agregar modulo usuarios con AG Grid + tema shadcn + integracion KAPPA employees
This commit is contained in:
@@ -12,6 +12,8 @@
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"ag-grid-community": "^35.3.0",
|
||||
"ag-grid-vue3": "^35.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"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=="],
|
||||
|
||||
"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-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"ag-grid-community": "^35.3.0",
|
||||
"ag-grid-vue3": "^35.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.0.4",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
KappaPlanningEntry,
|
||||
KappaBusinessRule,
|
||||
KappaRequirement,
|
||||
KappaEmployee,
|
||||
PaginatedResponse,
|
||||
} from '@/types/kappa'
|
||||
|
||||
const BASE = '/api'
|
||||
@@ -92,6 +94,10 @@ class KappaAPI {
|
||||
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[]> {
|
||||
const path = initiativeId ? `/userstorys/?initiative=${initiativeId}` : '/userstorys/'
|
||||
return this.request<KappaUserStory[]>('GET', path)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -5,6 +5,10 @@
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
@import "ag-grid-community/styles/ag-grid.css";
|
||||
|
||||
@import "./assets/ag-grid-alpha.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
|
||||
@@ -101,6 +101,21 @@ export interface KappaRequirement {
|
||||
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> {
|
||||
count: number
|
||||
next: string | null
|
||||
|
||||
@@ -6,6 +6,7 @@ import SiteHeader from "@/components/dashboard/SiteHeader.vue"
|
||||
import SectionCards from "@/components/dashboard/SectionCards.vue"
|
||||
import DashboardView from "@/views/DashboardView.vue"
|
||||
import ProjectListView from "@/views/ProjectListView.vue"
|
||||
import UsersView from "@/views/UsersView.vue"
|
||||
|
||||
const sidebarStyle = {
|
||||
'--sidebar-width': '16rem',
|
||||
@@ -149,6 +150,9 @@ const tabContent: Record<string, { title: string; description: string; cards: {
|
||||
@select-project="openProject()"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="activeTab === 'team'">
|
||||
<UsersView />
|
||||
</template>
|
||||
<template v-else>
|
||||
<SectionCards :cards="tabContent[activeTab]?.cards ?? []" />
|
||||
</template>
|
||||
|
||||
@@ -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