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:
+179
-11
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user