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:
2026-05-29 00:14:30 -05:00
parent 4e90f6f7b2
commit fff84c552c
6 changed files with 222 additions and 4 deletions
+32 -1
View File
@@ -69,6 +69,33 @@ export interface QAPlanRecord {
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 & {
settings: Dexie.Table<SettingEntry, string>
project_docs: Dexie.Table<ProjectDocRecord, number>
@@ -77,9 +104,11 @@ const db = new Dexie('alpha-core') as Dexie & {
project_state: Dexie.Table<ProjectStateRecord, number>
hu_drafts: Dexie.Table<HuDraftRecord, 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',
project_docs: '&projectId, projectName, updatedAt',
sessions: '++id, projectId, date',
@@ -87,6 +116,8 @@ db.version(5).stores({
project_state: '&projectId',
hu_drafts: '&id, projectId, syncStatus',
qa_plans: '&id, projectId',
users: '&id',
lookups: '&[type+id], type',
})
export default db
+4
View File
@@ -99,6 +99,10 @@ class KappaAPI {
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>> {
return this.request<PaginatedResponse<KappaEmployee>>('GET', `/employees/?page=${page}`)
}
+95
View File
@@ -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
}
}
+38
View File
@@ -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 }))
}
+38
View File
@@ -2,6 +2,8 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { kappa } from '@/services/kappa-api'
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'
export interface AlphaUser {
@@ -28,6 +30,10 @@ export const useUsersStore = defineStore('users', () => {
const error = ref<string | null>(null)
const totalPages = 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 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) {
await tauriDb.updateUserFields(id, role, seniority, cellId)
const idx = users.value.findIndex(u => u.id === id)
@@ -164,11 +197,16 @@ export const useUsersStore = defineStore('users', () => {
cells,
loading,
error,
syncing,
syncProgress,
syncTotal,
roles,
totalPages,
currentPage,
activeUsers,
devsByProject,
fetchAll,
syncDetails,
updateLocal,
updateUserFields,
createCell,
+15 -3
View File
@@ -25,6 +25,7 @@ import {
IconChevronLeft,
IconChevronRight,
IconSearch,
IconRefresh,
} from '@tabler/icons-vue'
const { t } = useI18n()
@@ -114,9 +115,18 @@ onMounted(() => store.fetchAll())
{{ t('users.teamSubtitle', { users: store.users.length, emps: store.employees.length }) }}
</p>
</div>
<Badge variant="outline" class="text-sm">
{{ t('users.activeCount', { count: stats.active }) }}
</Badge>
<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">
{{ t('users.activeCount', { count: stats.active }) }}
</Badge>
</div>
</div>
<!-- Loading -->
@@ -247,6 +257,7 @@ onMounted(() => store.fetchAll())
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[60px]">ID</TableHead>
<TableHead>{{ t('users.name') }}</TableHead>
<TableHead>{{ t('users.email') }}</TableHead>
<TableHead class="w-[80px]">{{ t('users.role') }}</TableHead>
@@ -258,6 +269,7 @@ onMounted(() => store.fetchAll())
</TableHeader>
<TableBody>
<TableRow v-for="user in paginatedUsers" :key="user.id">
<TableCell class="text-xs font-mono text-muted-foreground">{{ user.id }}</TableCell>
<TableCell>
<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">