DashboardView: asignación real desde KAPPA + status_name desde lookup + columna asignado como texto

- KappaUserStory ampliado con assigned_to, asignado_a, assigned_name
- EnrichedUserStory ahora tiene _assignedUserId, _assignedName, _statusName
- parseAssignedUser() extrae user ID desde múltiples formatos de KAPPA
- syncHUsToTurso persiste assigned_to en BD local
- DashboardView: columna Asignado muestra nombre real desde KAPPA (texto, no Select)
- statuses-db.ts: lookup de estados con seed en Dexie + resolveStatusName()
  seguro para booleanos/números (String() antes de trim)
- tauri-db.ts: fallback Dexie para get/save user_stories (funciona en bun dev)
- db.ts: nueva tabla user_stories en Dexie (version 8)
- Filtros de tabla alineados a la derecha (justify-end)
This commit is contained in:
2026-05-29 02:52:27 -05:00
parent 416784b2fd
commit 2f8d79a624
9 changed files with 340 additions and 16 deletions
+179 -11
View File
@@ -3,6 +3,8 @@ import { computed, watch, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProjectsStore } from '@/stores/projects'
import { useWorkItemsStore } from '@/stores/workitems'
import { useUsersStore } from '@/stores/users'
import { storage } from '@/services/storage'
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
import { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus, Brain, Sparkles, Loader2, CheckCircle2, XCircle, Send, ChevronDown } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
@@ -14,6 +16,13 @@ import { kappa } from '@/services/kappa-api'
import { generateAndSavePlan, getQAPlans, type HUQAPlan } from '@/services/qa-analyzer'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
@@ -26,9 +35,125 @@ import {
const { t } = useI18n()
const projects = useProjectsStore()
const workItems = useWorkItemsStore()
const usersStore = useUsersStore()
const project = computed(() => projects.selected)
// ─── Team members for this project ────────────────────
const projectTeam = computed(() => {
const p = project.value
if (!p) return []
const ids = storage.getJSON<number[]>(`project_team_${p.id}`)
if (!ids) return []
return ids
.map(id => usersStore.users.find(u => u.id === id))
.filter(Boolean)
.map(u => ({ id: u!.id, name: u!.full_name || u!.email }))
})
const allAssignableUsers = computed(() => {
// Todos los usuarios activos + cualquier usuario asignado por KAPPA
const seen = new Set<number>()
const list: { id: number; name: string }[] = []
for (const u of usersStore.activeUsers) {
seen.add(u.id)
list.push({ id: u.id, name: u.full_name || u.email })
}
// Incluir usuarios asignados por KAPPA aunque no estén en activeUsers
for (const hu of workItems.userStories) {
const uid = hu._assignedUserId
if (uid != null && !seen.has(uid)) {
seen.add(uid)
list.push({ id: uid, name: hu._assignedName || `Usuario #${uid}` })
}
}
return list.sort((a, b) => a.name.localeCompare(b.name))
})
// ─── HU assignments (local) ───────────────────────────
const STORAGE_KEY = 'hu_assignments'
function loadAssignments(): Record<number, number> {
return storage.getJSON<Record<number, number>>(STORAGE_KEY) || {}
}
function saveAssignment(huId: number, userId: number | null) {
const all = loadAssignments()
if (userId === null) {
delete all[huId]
} else {
all[huId] = userId
}
storage.setJSON(STORAGE_KEY, all)
}
function getAssignedUserId(hu: { id?: number; _assignedUserId?: number | null }): number | null {
// 1. KAPPA asigna directo
if (hu._assignedUserId != null && hu._assignedUserId > 0) return hu._assignedUserId
// 2. Fallback: asignación manual en localStorage
if (hu.id != null) {
const all = loadAssignments()
return all[hu.id] ?? null
}
return null
}
function assignedName(hu: { id?: number; _assignedUserId?: number | null; _assignedName?: string }): string {
const uid = getAssignedUserId(hu)
if (!uid) return ''
const u = usersStore.users.find(u => u.id === uid)
return u?.full_name || u?.email || hu._assignedName || ''
}
// ─── Priority helpers ─────────────────────────────────
function priorityVariant(p: unknown) {
const s = String(p ?? '').toLowerCase()
if (['alta', 'high', 'critical', 'urgente'].includes(s)) return 'destructive'
if (['media', 'medium', 'normal'].includes(s)) return 'default'
if (['baja', 'low'].includes(s)) return 'secondary'
return 'outline'
}
function priorityLabel(p: unknown) {
const s = String(p ?? '').toLowerCase()
if (['alta', 'high'].includes(s)) return 'Alta'
if (['media', 'medium', 'normal'].includes(s)) return 'Media'
if (['baja', 'low'].includes(s)) return 'Baja'
if (s === 'critical') return 'Crítica'
if (s === 'urgente') return 'Urgente'
return String(p ?? '—')
}
// ─── Filters ─────────────────────────────────────────
const filterStatus = ref('__all')
const filterPriority = ref('__all')
const filterAssigned = ref('__all')
const filteredHUs = computed(() => {
let list = workItems.userStories
if (filterStatus.value !== '__all') {
list = list.filter(h => {
const s = String(h.status ?? '').toLowerCase()
return s === filterStatus.value
})
}
if (filterPriority.value !== '__all') {
list = list.filter(h => {
const p = String(h.priority ?? '').toLowerCase()
return p === filterPriority.value
})
}
if (filterAssigned.value !== '__all') {
if (filterAssigned.value === '__none') {
list = list.filter(h => !getAssignedUserId(h))
} else {
const uid = Number(filterAssigned.value)
list = list.filter(h => getAssignedUserId(h) === uid)
}
}
return list
})
const emit = defineEmits<{
'navigate-settings': []
}>()
@@ -226,6 +351,9 @@ watch(
async (id) => {
if (id) {
await workItems.fetchWorkItems(id)
if (usersStore.users.length === 0) {
usersStore.fetchAll()
}
}
},
{ immediate: true }
@@ -506,29 +634,65 @@ const statusLabel = (status: unknown) => {
<Card id="dashboard-hus-table">
<CardHeader class="flex flex-row items-center justify-between pb-2">
<CardTitle class="text-sm font-medium">{{ t('dashboard.userStoriesTitle') }}</CardTitle>
<Badge variant="outline" class="text-xs">{{ t('dashboard.husCount', { count: workItems.userStories.length }) }}</Badge>
<Badge variant="outline" class="text-xs">{{ t('dashboard.husCount', { count: filteredHUs.length }) }} / {{ workItems.userStories.length }}</Badge>
</CardHeader>
<CardContent>
<Table v-if="workItems.userStories.length > 0">
<CardContent class="space-y-3">
<!-- Filters -->
<div class="flex items-center gap-2 flex-wrap justify-end">
<Select v-model="filterStatus">
<SelectTrigger class="h-8 w-[130px] text-xs"><SelectValue :placeholder="t('dashboard.status')" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all" class="text-xs">{{ t('dashboard.filterAll') }}</SelectItem>
<SelectItem value="todo" class="text-xs">{{ t('status.todo') }}</SelectItem>
<SelectItem value="in_progress" class="text-xs">{{ t('status.inProgress') }}</SelectItem>
<SelectItem value="done" class="text-xs">{{ t('status.completed') }}</SelectItem>
<SelectItem value="blocked" class="text-xs">{{ t('status.blocked') }}</SelectItem>
<SelectItem value="review" class="text-xs">{{ t('status.review') }}</SelectItem>
<SelectItem value="testing" class="text-xs">{{ t('status.testing') }}</SelectItem>
</SelectContent>
</Select>
<Select v-model="filterPriority">
<SelectTrigger class="h-8 w-[120px] text-xs"><SelectValue :placeholder="t('dashboard.priority')" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all" class="text-xs">{{ t('dashboard.filterAll') }}</SelectItem>
<SelectItem value="alta" class="text-xs">Alta</SelectItem>
<SelectItem value="media" class="text-xs">Media</SelectItem>
<SelectItem value="baja" class="text-xs">Baja</SelectItem>
<SelectItem value="critical" class="text-xs">Crítica</SelectItem>
<SelectItem value="urgente" class="text-xs">Urgente</SelectItem>
</SelectContent>
</Select>
<Select v-model="filterAssigned">
<SelectTrigger class="h-8 w-[150px] text-xs"><SelectValue :placeholder="t('dashboard.assignedTo')" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all" class="text-xs">{{ t('dashboard.filterAll') }}</SelectItem>
<SelectItem value="__none" class="text-xs">{{ t('dashboard.unassigned') }}</SelectItem>
<SelectItem v-for="m in allAssignableUsers" :key="m.id" :value="String(m.id)" class="text-xs">{{ m.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<Table v-if="filteredHUs.length > 0">
<TableHeader>
<TableRow>
<TableHead class="w-[80px]">{{ t('dashboard.code') }}</TableHead>
<TableHead class="w-[60px]">{{ t('users.role') }}</TableHead>
<TableHead>{{ t('dashboard.title') }}</TableHead>
<TableHead class="w-[110px]">{{ t('dashboard.status') }}</TableHead>
<TableHead class="w-[90px] text-right">{{ t('dashboard.priority') }}</TableHead>
<TableHead class="w-[90px]">{{ t('dashboard.priority') }}</TableHead>
<TableHead class="w-[140px]">{{ t('dashboard.assignedTo') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="hu in workItems.userStories" :key="hu.id">
<TableRow v-for="hu in filteredHUs" :key="hu.id">
<TableCell class="font-mono text-xs text-muted-foreground">{{ hu.code || `HU-${hu.id}` }}</TableCell>
<TableCell>
<span class="inline-flex items-center rounded px-1 py-0.5 text-[10px] font-bold" :class="getTypeColor(hu._itemType)">{{ getTypeLabel(hu._itemType) }}</span>
</TableCell>
<TableCell class="text-sm max-w-[280px] truncate flex items-center gap-1">
<AlertTriangle v-if="hu.has_impairment" class="size-3.5 text-amber-500 flex-shrink-0" title="Tiene impedimentos pendientes" />
<span class="truncate">{{ hu._cleanTitle || hu.title }}</span>
<span v-if="hu._criteriaList?.length" class="group relative inline-flex flex-shrink-0 cursor-help">
<TableCell class="text-sm flex items-center gap-1 py-3">
<AlertTriangle v-if="hu.has_impairment" class="size-3.5 text-amber-500 shrink-0" title="Tiene impedimentos pendientes" />
<span>{{ hu._cleanTitle || hu.title }}</span>
<span v-if="hu._criteriaList?.length" class="group relative inline-flex shrink-0 cursor-help ml-1">
<Info class="size-3.5 text-muted-foreground hover:text-foreground transition-colors" />
<div class="absolute bottom-full left-0 mb-2 w-72 p-3 rounded-lg border bg-popover text-popover-foreground text-xs shadow-md opacity-0 group-hover:opacity-100 transition-opacity z-50 pointer-events-none">
<p class="font-semibold mb-1.5 text-[11px] uppercase tracking-wider text-muted-foreground">Criterios de aceptación</p>
@@ -536,8 +700,12 @@ const statusLabel = (status: unknown) => {
</div>
</span>
</TableCell>
<TableCell><Badge :variant="statusVariant(hu.status || '')" class="text-xs capitalize">{{ statusLabel(hu.status || '') }}</Badge></TableCell>
<TableCell class="text-right text-xs text-muted-foreground">{{ hu.priority || '—' }}</TableCell>
<TableCell><Badge :variant="statusVariant(hu.status || '')" class="text-xs capitalize">{{ hu._statusName || statusLabel(hu.status || '') }}</Badge></TableCell>
<TableCell><Badge :variant="priorityVariant(hu.priority)" class="text-xs">{{ priorityLabel(hu.priority) }}</Badge></TableCell>
<TableCell>
<span v-if="assignedName(hu)" class="text-xs">{{ assignedName(hu) }}</span>
<span v-else class="text-xs text-muted-foreground italic">{{ t('dashboard.unassigned') }}</span>
</TableCell>
</TableRow>
</TableBody>
</Table>