users: tabla + sync detalle + lookups para roles/companies
- db.ts v6: tablas users + lookups con schema
- users-db.ts: CRUD users + lookups
- user-sync.ts: fetch /api/users/{id}/ con concurrencia 5
- kappa-api.ts: +getUserDetail(id)
- users store: +syncDetails() con progreso + mapeo roleId→name
- UsersView: boton 'Sincronizar roles' con progreso
- UserRecord: username, isStaff, phone, position, lastActive, createdAt
This commit is contained in:
+32
-1
@@ -69,6 +69,33 @@ export interface QAPlanRecord {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserRecord {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
fullName: string
|
||||||
|
username: string | null
|
||||||
|
isActive: boolean
|
||||||
|
isStaff: boolean
|
||||||
|
roleId: number | null
|
||||||
|
cellId: number | null
|
||||||
|
seniority: string | null
|
||||||
|
companyId: number | null
|
||||||
|
phone: string | null
|
||||||
|
position: string | null
|
||||||
|
lastActive: string | null
|
||||||
|
createdAt: string | null
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LookupRecord {
|
||||||
|
type: string // 'role' | 'company' | 'seniority'
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
|
||||||
const db = new Dexie('alpha-core') as Dexie & {
|
const db = new Dexie('alpha-core') as Dexie & {
|
||||||
settings: Dexie.Table<SettingEntry, string>
|
settings: Dexie.Table<SettingEntry, string>
|
||||||
project_docs: Dexie.Table<ProjectDocRecord, number>
|
project_docs: Dexie.Table<ProjectDocRecord, number>
|
||||||
@@ -77,9 +104,11 @@ const db = new Dexie('alpha-core') as Dexie & {
|
|||||||
project_state: Dexie.Table<ProjectStateRecord, number>
|
project_state: Dexie.Table<ProjectStateRecord, number>
|
||||||
hu_drafts: Dexie.Table<HuDraftRecord, string>
|
hu_drafts: Dexie.Table<HuDraftRecord, string>
|
||||||
qa_plans: Dexie.Table<QAPlanRecord, string>
|
qa_plans: Dexie.Table<QAPlanRecord, string>
|
||||||
|
users: Dexie.Table<UserRecord, number>
|
||||||
|
lookups: Dexie.Table<LookupRecord, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
db.version(5).stores({
|
db.version(6).stores({
|
||||||
settings: '&key',
|
settings: '&key',
|
||||||
project_docs: '&projectId, projectName, updatedAt',
|
project_docs: '&projectId, projectName, updatedAt',
|
||||||
sessions: '++id, projectId, date',
|
sessions: '++id, projectId, date',
|
||||||
@@ -87,6 +116,8 @@ db.version(5).stores({
|
|||||||
project_state: '&projectId',
|
project_state: '&projectId',
|
||||||
hu_drafts: '&id, projectId, syncStatus',
|
hu_drafts: '&id, projectId, syncStatus',
|
||||||
qa_plans: '&id, projectId',
|
qa_plans: '&id, projectId',
|
||||||
|
users: '&id',
|
||||||
|
lookups: '&[type+id], type',
|
||||||
})
|
})
|
||||||
|
|
||||||
export default db
|
export default db
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ class KappaAPI {
|
|||||||
return this.request<unknown[]>('GET', '/users/all/')
|
return this.request<unknown[]>('GET', '/users/all/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserDetail(id: number): Promise<Record<string, unknown>> {
|
||||||
|
return this.request<Record<string, unknown>>('GET', `/users/${id}/`)
|
||||||
|
}
|
||||||
|
|
||||||
async getEmployees(page = 1): Promise<PaginatedResponse<KappaEmployee>> {
|
async getEmployees(page = 1): Promise<PaginatedResponse<KappaEmployee>> {
|
||||||
return this.request<PaginatedResponse<KappaEmployee>>('GET', `/employees/?page=${page}`)
|
return this.request<PaginatedResponse<KappaEmployee>>('GET', `/employees/?page=${page}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { kappa } from '@/services/kappa-api'
|
||||||
|
import { saveUser, saveLookup } from '@/services/users-db'
|
||||||
|
import type { UserRecord } from '@/services/db'
|
||||||
|
|
||||||
|
const CONCURRENCY = 5
|
||||||
|
|
||||||
|
interface RawUserDetail {
|
||||||
|
id: number
|
||||||
|
email?: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
username?: string
|
||||||
|
is_active?: boolean
|
||||||
|
is_staff?: boolean
|
||||||
|
phone?: string
|
||||||
|
position?: string
|
||||||
|
role?: number | null
|
||||||
|
role_detail?: { id: number; name: string } | null
|
||||||
|
employee?: { company?: number | null; company_detail?: { id: number; name: string } | null } | null
|
||||||
|
last_login?: string
|
||||||
|
date_joined?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza detalles de usuarios desde KAPPA.
|
||||||
|
* Itera sobre los IDs dados, con límite de concurrencia.
|
||||||
|
* Guarda en tablas `users` + `lookups` (roles, companies).
|
||||||
|
*/
|
||||||
|
export async function syncUserDetails(userIds: number[], onProgress?: (done: number, total: number) => void): Promise<{ ok: number; fail: number }> {
|
||||||
|
let ok = 0
|
||||||
|
let fail = 0
|
||||||
|
const total = userIds.length
|
||||||
|
|
||||||
|
// Procesar en lotes concurrentes
|
||||||
|
for (let i = 0; i < userIds.length; i += CONCURRENCY) {
|
||||||
|
const batch = userIds.slice(i, i + CONCURRENCY)
|
||||||
|
const results = await Promise.allSettled(batch.map(id => fetchAndSaveUser(id)))
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === 'fulfilled' && r.value) ok++
|
||||||
|
else fail++
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.(Math.min(i + CONCURRENCY, total), total)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[UserSync] ${ok} OK, ${fail} fail de ${total}`)
|
||||||
|
return { ok, fail }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndSaveUser(id: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const raw = await kappa.getUserDetail(id) as unknown as RawUserDetail
|
||||||
|
if (!raw || !raw.id) return false
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
// Guardar usuario
|
||||||
|
const user: UserRecord = {
|
||||||
|
id: raw.id,
|
||||||
|
email: raw.email || '',
|
||||||
|
firstName: raw.first_name || '',
|
||||||
|
lastName: raw.last_name || '',
|
||||||
|
fullName: `${raw.first_name || ''} ${raw.last_name || ''}`.trim() || raw.email || '',
|
||||||
|
username: raw.username || null,
|
||||||
|
isActive: raw.is_active !== undefined ? raw.is_active : true,
|
||||||
|
isStaff: raw.is_staff || false,
|
||||||
|
roleId: raw.role ?? null,
|
||||||
|
cellId: null,
|
||||||
|
seniority: null,
|
||||||
|
companyId: raw.employee?.company ?? null,
|
||||||
|
phone: raw.phone || null,
|
||||||
|
position: raw.position || null,
|
||||||
|
lastActive: raw.last_login || null,
|
||||||
|
createdAt: raw.date_joined || null,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
await saveUser(user)
|
||||||
|
|
||||||
|
// Guardar lookup de rol
|
||||||
|
if (raw.role_detail?.id && raw.role_detail?.name) {
|
||||||
|
await saveLookup('role', raw.role_detail.id, raw.role_detail.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guardar lookup de compañía
|
||||||
|
if (raw.employee?.company_detail?.id && raw.employee?.company_detail?.name) {
|
||||||
|
await saveLookup('company', raw.employee.company_detail.id, raw.employee.company_detail.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[UserSync] Error fetching user ${id}:`, e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import db, { type UserRecord, type LookupRecord } from '@/services/db'
|
||||||
|
|
||||||
|
// ─── Users ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function saveUser(u: UserRecord) {
|
||||||
|
await db.users.put(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsers(): Promise<UserRecord[]> {
|
||||||
|
return db.users.toArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(id: number): Promise<UserRecord | undefined> {
|
||||||
|
return db.users.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: number) {
|
||||||
|
await db.users.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lookups ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function saveLookup(type: string, id: number, name: string, description?: string) {
|
||||||
|
await db.lookups.put({ type, id, name, description: description ?? null })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLookups(type: string): Promise<LookupRecord[]> {
|
||||||
|
return db.lookups.where('type').equals(type).toArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLookup(type: string, id: number): Promise<LookupRecord | undefined> {
|
||||||
|
return db.lookups.get([type, id])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupsAsOptions(type: string): Promise<{ value: number; label: string }[]> {
|
||||||
|
const items = await getLookups(type)
|
||||||
|
return items.map(i => ({ value: i.id, label: i.name }))
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { kappa } from '@/services/kappa-api'
|
import { kappa } from '@/services/kappa-api'
|
||||||
import { tauriDb, type AlphaUserRecord, type CellRecord } from '@/services/tauri-db'
|
import { tauriDb, type AlphaUserRecord, type CellRecord } from '@/services/tauri-db'
|
||||||
|
import { syncUserDetails } from '@/services/user-sync'
|
||||||
|
import { getLookups, getUsers } from '@/services/users-db'
|
||||||
import type { KappaEmployee } from '@/types/kappa'
|
import type { KappaEmployee } from '@/types/kappa'
|
||||||
|
|
||||||
export interface AlphaUser {
|
export interface AlphaUser {
|
||||||
@@ -28,6 +30,10 @@ export const useUsersStore = defineStore('users', () => {
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const totalPages = ref(1)
|
const totalPages = ref(1)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
const syncing = ref(false)
|
||||||
|
const syncProgress = ref(0)
|
||||||
|
const syncTotal = ref(0)
|
||||||
|
const roles = ref<{ id: number; name: string }[]>([])
|
||||||
|
|
||||||
const activeUsers = computed(() => users.value.filter(u => u.is_active))
|
const activeUsers = computed(() => users.value.filter(u => u.is_active))
|
||||||
const devsByProject = computed(() => {
|
const devsByProject = computed(() => {
|
||||||
@@ -123,6 +129,33 @@ export const useUsersStore = defineStore('users', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncDetails() {
|
||||||
|
const userIds = users.value.map(u => u.id)
|
||||||
|
if (userIds.length === 0) return
|
||||||
|
syncing.value = true
|
||||||
|
syncProgress.value = 0
|
||||||
|
syncTotal.value = userIds.length
|
||||||
|
try {
|
||||||
|
await syncUserDetails(userIds, (done, total) => { syncProgress.value = done; syncTotal.value = total })
|
||||||
|
const roleItems = await getLookups('role')
|
||||||
|
roles.value = roleItems.map(r => ({ id: r.id, name: r.name }))
|
||||||
|
const dbUsers = await getUsers()
|
||||||
|
// Mapear roleId → role name a AlphaUser
|
||||||
|
for (const u of users.value) {
|
||||||
|
const record = dbUsers.find(d => d.id === u.id)
|
||||||
|
if (record?.roleId) {
|
||||||
|
const role = roles.value.find(r => r.id === record.roleId)
|
||||||
|
if (role) u.role = role.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[Users] Sync complete: ${dbUsers.length} users in DB, ${roles.value.length} roles`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Users] Sync error:', e)
|
||||||
|
} finally {
|
||||||
|
syncing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function updateUserFields(id: number, role: string | null, seniority: string | null, cellId: number | null) {
|
async function updateUserFields(id: number, role: string | null, seniority: string | null, cellId: number | null) {
|
||||||
await tauriDb.updateUserFields(id, role, seniority, cellId)
|
await tauriDb.updateUserFields(id, role, seniority, cellId)
|
||||||
const idx = users.value.findIndex(u => u.id === id)
|
const idx = users.value.findIndex(u => u.id === id)
|
||||||
@@ -164,11 +197,16 @@ export const useUsersStore = defineStore('users', () => {
|
|||||||
cells,
|
cells,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
syncing,
|
||||||
|
syncProgress,
|
||||||
|
syncTotal,
|
||||||
|
roles,
|
||||||
totalPages,
|
totalPages,
|
||||||
currentPage,
|
currentPage,
|
||||||
activeUsers,
|
activeUsers,
|
||||||
devsByProject,
|
devsByProject,
|
||||||
fetchAll,
|
fetchAll,
|
||||||
|
syncDetails,
|
||||||
updateLocal,
|
updateLocal,
|
||||||
updateUserFields,
|
updateUserFields,
|
||||||
createCell,
|
createCell,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
IconChevronLeft,
|
IconChevronLeft,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
|
IconRefresh,
|
||||||
} from '@tabler/icons-vue'
|
} from '@tabler/icons-vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -114,10 +115,19 @@ onMounted(() => store.fetchAll())
|
|||||||
{{ t('users.teamSubtitle', { users: store.users.length, emps: store.employees.length }) }}
|
{{ t('users.teamSubtitle', { users: store.users.length, emps: store.employees.length }) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button v-if="!store.syncing" size="sm" variant="outline" @click="store.syncDetails()">
|
||||||
|
<IconRefresh class="size-4 mr-1" />
|
||||||
|
Sincronizar roles
|
||||||
|
</Button>
|
||||||
|
<span v-if="store.syncing" class="text-xs text-muted-foreground">
|
||||||
|
Sincronizando {{ store.syncProgress }}/{{ store.syncTotal }}...
|
||||||
|
</span>
|
||||||
<Badge variant="outline" class="text-sm">
|
<Badge variant="outline" class="text-sm">
|
||||||
{{ t('users.activeCount', { count: stats.active }) }}
|
{{ t('users.activeCount', { count: stats.active }) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<template v-if="store.loading">
|
<template v-if="store.loading">
|
||||||
@@ -247,6 +257,7 @@ onMounted(() => store.fetchAll())
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead class="w-[60px]">ID</TableHead>
|
||||||
<TableHead>{{ t('users.name') }}</TableHead>
|
<TableHead>{{ t('users.name') }}</TableHead>
|
||||||
<TableHead>{{ t('users.email') }}</TableHead>
|
<TableHead>{{ t('users.email') }}</TableHead>
|
||||||
<TableHead class="w-[80px]">{{ t('users.role') }}</TableHead>
|
<TableHead class="w-[80px]">{{ t('users.role') }}</TableHead>
|
||||||
@@ -258,6 +269,7 @@ onMounted(() => store.fetchAll())
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-for="user in paginatedUsers" :key="user.id">
|
<TableRow v-for="user in paginatedUsers" :key="user.id">
|
||||||
|
<TableCell class="text-xs font-mono text-muted-foreground">{{ user.id }}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex items-center gap-2">
|
<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">
|
<div class="flex items-center justify-center size-7 rounded-full bg-primary/10 text-primary text-xs font-semibold shrink-0">
|
||||||
|
|||||||
Reference in New Issue
Block a user