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:
@@ -111,7 +111,10 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"priority": "Priority",
|
"priority": "Priority",
|
||||||
"noUserStories": "No user stories",
|
"noUserStories": "No user stories",
|
||||||
"selectProject": "Select a project from the sidebar"
|
"selectProject": "Select a project from the sidebar",
|
||||||
|
"filterAll": "All",
|
||||||
|
"assignedTo": "Assigned to",
|
||||||
|
"unassigned": "Unassigned"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
|||||||
@@ -111,7 +111,10 @@
|
|||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
"priority": "Prioridad",
|
"priority": "Prioridad",
|
||||||
"noUserStories": "Sin historias de usuario",
|
"noUserStories": "Sin historias de usuario",
|
||||||
"selectProject": "Seleccioná un proyecto del panel lateral"
|
"selectProject": "Seleccioná un proyecto del panel lateral",
|
||||||
|
"filterAll": "Todos",
|
||||||
|
"assignedTo": "Asignado",
|
||||||
|
"unassigned": "Sin asignar"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Activo",
|
"active": "Activo",
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ import { i18n } from './i18n'
|
|||||||
import 'ag-grid-community/styles/ag-grid.css'
|
import 'ag-grid-community/styles/ag-grid.css'
|
||||||
import './assets/ag-grid-alpha.css'
|
import './assets/ag-grid-alpha.css'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
import db from '@/services/db'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
|
// Debug: exponer BD para consultas desde consola del navegador
|
||||||
|
;(window as any).__db = db
|
||||||
|
|||||||
+19
-1
@@ -111,6 +111,22 @@ export interface CellMemberRecord {
|
|||||||
addedAt: string
|
addedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DexieUserStory {
|
||||||
|
id: number
|
||||||
|
initiative_id: number
|
||||||
|
title: string
|
||||||
|
status: string | null
|
||||||
|
priority: string | null
|
||||||
|
assigned_to: number | null
|
||||||
|
code: string | null
|
||||||
|
description: string | null
|
||||||
|
acceptance_criteria: string | null
|
||||||
|
story_points: number | null
|
||||||
|
sprint: string | null
|
||||||
|
has_impairment: boolean | number
|
||||||
|
created_at: 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>
|
||||||
@@ -123,9 +139,10 @@ const db = new Dexie('alpha-core') as Dexie & {
|
|||||||
lookups: Dexie.Table<LookupRecord, string>
|
lookups: Dexie.Table<LookupRecord, string>
|
||||||
cells: Dexie.Table<CellRecord, string>
|
cells: Dexie.Table<CellRecord, string>
|
||||||
cell_members: Dexie.Table<CellMemberRecord, string>
|
cell_members: Dexie.Table<CellMemberRecord, string>
|
||||||
|
user_stories: Dexie.Table<DexieUserStory, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
db.version(7).stores({
|
db.version(8).stores({
|
||||||
settings: '&key',
|
settings: '&key',
|
||||||
project_docs: '&projectId, projectName, updatedAt',
|
project_docs: '&projectId, projectName, updatedAt',
|
||||||
sessions: '++id, projectId, date',
|
sessions: '++id, projectId, date',
|
||||||
@@ -137,6 +154,7 @@ db.version(7).stores({
|
|||||||
lookups: '&[type+id], type',
|
lookups: '&[type+id], type',
|
||||||
cells: '&id',
|
cells: '&id',
|
||||||
cell_members: '[cellId+userId], cellId, userId',
|
cell_members: '[cellId+userId], cellId, userId',
|
||||||
|
user_stories: '&id, initiative_id',
|
||||||
})
|
})
|
||||||
|
|
||||||
export default db
|
export default db
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { saveLookup, getLookups } from '@/services/users-db'
|
||||||
|
|
||||||
|
export interface StatusDef {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUSES: StatusDef[] = [
|
||||||
|
{ id: 1, name: 'Por hacer', description: 'Tarea pendiente por iniciar' },
|
||||||
|
{ id: 2, name: 'En progreso', description: 'Tarea actualmente en desarrollo' },
|
||||||
|
{ id: 3, name: 'En revisión', description: 'Tarea en proceso de revisión' },
|
||||||
|
{ id: 4, name: 'En pruebas', description: 'Tarea en fase de pruebas' },
|
||||||
|
{ id: 5, name: 'Completado', description: 'Tarea finalizada' },
|
||||||
|
{ id: 6, name: 'Bloqueado', description: 'Tarea bloqueada por dependencias' },
|
||||||
|
{ id: 7, name: 'Cancelado', description: 'Tarea cancelada definitivamente' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, number> = {
|
||||||
|
'todo': 1,
|
||||||
|
'por hacer': 1,
|
||||||
|
'in_progress': 2,
|
||||||
|
'doing': 2,
|
||||||
|
'wip': 2,
|
||||||
|
'active': 2,
|
||||||
|
'in progress': 2,
|
||||||
|
'en progreso': 2,
|
||||||
|
'true': 2,
|
||||||
|
'review': 3,
|
||||||
|
'revisión': 3,
|
||||||
|
'testing': 4,
|
||||||
|
'pruebas': 4,
|
||||||
|
'done': 5,
|
||||||
|
'completed': 5,
|
||||||
|
'closed': 5,
|
||||||
|
'finalizado': 5,
|
||||||
|
'blocked': 6,
|
||||||
|
'bloqueado': 6,
|
||||||
|
'cancelled': 7,
|
||||||
|
'cancelado': 7,
|
||||||
|
'false': 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedStatuses: Map<number, StatusDef> | null = null
|
||||||
|
|
||||||
|
export function resolveStatusName(raw: unknown): string {
|
||||||
|
const str = raw != null ? String(raw).trim().toLowerCase() : ''
|
||||||
|
if (!str) return 'Por hacer'
|
||||||
|
const id = STATUS_MAP[str]
|
||||||
|
if (!id) return String(raw)
|
||||||
|
const def = STATUSES.find(s => s.id === id)
|
||||||
|
return def?.name ?? String(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStatusId(raw: unknown): number {
|
||||||
|
const str = raw != null ? String(raw).trim().toLowerCase() : ''
|
||||||
|
if (!str) return 1
|
||||||
|
return STATUS_MAP[str] ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedStatusLookups(): Promise<void> {
|
||||||
|
for (const s of STATUSES) {
|
||||||
|
await saveLookup('status', s.id, s.name, s.description)
|
||||||
|
}
|
||||||
|
cachedStatuses = new Map(STATUSES.map(s => [s.id, s]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadStatusLookups(): Promise<StatusDef[]> {
|
||||||
|
const records = await getLookups('status')
|
||||||
|
if (records.length > 0) {
|
||||||
|
cachedStatuses = new Map(records.map(r => [r.id, { id: r.id, name: r.name, description: r.description ?? '' }]))
|
||||||
|
return Array.from(cachedStatuses.values())
|
||||||
|
}
|
||||||
|
return STATUSES
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedStatuses(): StatusDef[] {
|
||||||
|
if (cachedStatuses) return Array.from(cachedStatuses.values())
|
||||||
|
return STATUSES
|
||||||
|
}
|
||||||
@@ -43,6 +43,19 @@ async function fallbackInvoke<T>(cmd: string, args?: Record<string, unknown>): P
|
|||||||
if (u) try { await (db as any).table('alpha_users').put(u) } catch {}
|
if (u) try { await (db as any).table('alpha_users').put(u) } catch {}
|
||||||
return null as unknown as T
|
return null as unknown as T
|
||||||
}
|
}
|
||||||
|
// ─── User Stories → Dexie ──────────────────────────
|
||||||
|
case 'get_user_stories': {
|
||||||
|
const initiativeId = (args as any)?.initiativeId
|
||||||
|
try {
|
||||||
|
const all = await (db as any).table('user_stories').toArray()
|
||||||
|
return (initiativeId ? all.filter((r: any) => r.initiative_id === initiativeId) : all) as unknown as T
|
||||||
|
} catch { return [] as unknown as T }
|
||||||
|
}
|
||||||
|
case 'save_user_story': {
|
||||||
|
const s = (args as any)?.story
|
||||||
|
if (s) try { await (db as any).table('user_stories').put(s) } catch {}
|
||||||
|
return null as unknown as T
|
||||||
|
}
|
||||||
// ─── Por defecto: no hay fallback ─────────────────
|
// ─── Por defecto: no hay fallback ─────────────────
|
||||||
default:
|
default:
|
||||||
return null as unknown as T
|
return null as unknown as T
|
||||||
|
|||||||
+34
-2
@@ -6,6 +6,7 @@ import { tauriDb, type UserStoryRecord, type EpicRecord, type ImpairmentRecord }
|
|||||||
import { stripHtml } from '@/services/clean-html'
|
import { stripHtml } from '@/services/clean-html'
|
||||||
import { criteriaToJson, parseQuillList } from '@/services/clean-html'
|
import { criteriaToJson, parseQuillList } from '@/services/clean-html'
|
||||||
import { parseHierarchy, stripHierarchy, getItemType, type ItemType } from '@/services/hierarchy'
|
import { parseHierarchy, stripHierarchy, getItemType, type ItemType } from '@/services/hierarchy'
|
||||||
|
import { resolveStatusName, seedStatusLookups } from '@/services/statuses-db'
|
||||||
import type { KappaUserStory, KappaLogbookEntry, KappaPlanningEntry, KappaEpicDevelopment, KappaPending } from '@/types/kappa'
|
import type { KappaUserStory, KappaLogbookEntry, KappaPlanningEntry, KappaEpicDevelopment, KappaPending } from '@/types/kappa'
|
||||||
|
|
||||||
export interface EnrichedUserStory extends KappaUserStory {
|
export interface EnrichedUserStory extends KappaUserStory {
|
||||||
@@ -14,6 +15,9 @@ export interface EnrichedUserStory extends KappaUserStory {
|
|||||||
_cleanTitle: string
|
_cleanTitle: string
|
||||||
_criteriaList: string[]
|
_criteriaList: string[]
|
||||||
has_impairment: boolean
|
has_impairment: boolean
|
||||||
|
_assignedUserId: number | null
|
||||||
|
_assignedName: string
|
||||||
|
_statusName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnrichedEpic extends KappaEpicDevelopment {
|
export interface EnrichedEpic extends KappaEpicDevelopment {
|
||||||
@@ -59,10 +63,29 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function enrichHU(hu: { title?: string; id?: number; acceptance_criteria?: string | null; criterios_aceptacion?: string | null }, initiativeId: number): EnrichedUserStory {
|
function parseAssignedUser(hu: any): { id: number | null; name: string } {
|
||||||
|
// Intenta múltiples formatos que KAPPA puede devolver
|
||||||
|
if (hu.assigned_to != null && hu.assigned_to !== '') {
|
||||||
|
const id = Number(hu.assigned_to)
|
||||||
|
if (!isNaN(id)) return { id, name: hu.assigned_name || '' }
|
||||||
|
}
|
||||||
|
if (hu.asignado_a != null) {
|
||||||
|
if (Array.isArray(hu.asignado_a)) {
|
||||||
|
const first = hu.asignado_a[0]
|
||||||
|
if (first != null) {
|
||||||
|
const id = typeof first === 'object' ? Number(first.id) : Number(first)
|
||||||
|
if (!isNaN(id)) return { id, name: typeof first === 'object' ? first.name || first.full_name || '' : '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: null, name: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichHU(hu: { title?: string; id?: number; status?: string | null; acceptance_criteria?: string | null; criterios_aceptacion?: string | null; assigned_to?: number | null; asignado_a?: number[] | string[] | null; assigned_name?: string }, initiativeId: number): EnrichedUserStory {
|
||||||
const h = parseHierarchy(hu.title || '')
|
const h = parseHierarchy(hu.title || '')
|
||||||
const rawCriteria = hu.acceptance_criteria || hu.criterios_aceptacion || ''
|
const rawCriteria = hu.acceptance_criteria || hu.criterios_aceptacion || ''
|
||||||
const criteriaList = rawCriteria ? parseQuillList(rawCriteria) : []
|
const criteriaList = rawCriteria ? parseQuillList(rawCriteria) : []
|
||||||
|
const { id: assignedUserId, name: assignedName } = parseAssignedUser(hu)
|
||||||
return {
|
return {
|
||||||
id: hu.id ?? 0,
|
id: hu.id ?? 0,
|
||||||
title: hu.title || '',
|
title: hu.title || '',
|
||||||
@@ -74,6 +97,9 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
_cleanTitle: h ? stripHierarchy(hu.title || '') : (hu.title || ''),
|
_cleanTitle: h ? stripHierarchy(hu.title || '') : (hu.title || ''),
|
||||||
_criteriaList: criteriaList,
|
_criteriaList: criteriaList,
|
||||||
has_impairment: false,
|
has_impairment: false,
|
||||||
|
_assignedUserId: assignedUserId,
|
||||||
|
_assignedName: assignedName,
|
||||||
|
_statusName: resolveStatusName(hu.status),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +130,9 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isFirstVisit = !firstVisit.value.has(id)
|
const isFirstVisit = !firstVisit.value.has(id)
|
||||||
|
if (isFirstVisit) {
|
||||||
|
seedStatusLookups().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Cargar desde Turso (instantáneo)
|
// 1. Cargar desde Turso (instantáneo)
|
||||||
@@ -116,6 +145,7 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
epics.value = localEpics.map(e => enrichEpic(e, id))
|
epics.value = localEpics.map(e => enrichEpic(e, id))
|
||||||
|
|
||||||
userStories.value = localHUs.map(hu => enrichHU(hu, id))
|
userStories.value = localHUs.map(hu => enrichHU(hu, id))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Consultar KAPPA (siempre, para detectar cambios)
|
// 2. Consultar KAPPA (siempre, para detectar cambios)
|
||||||
@@ -180,6 +210,8 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
hasImpairment = impairments.some(p => !p.status)
|
hasImpairment = impairments.some(p => !p.status)
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
const { id: assignedUserId } = parseAssignedUser(hu)
|
||||||
|
|
||||||
await tauriDb.saveUserStory({
|
await tauriDb.saveUserStory({
|
||||||
id: huId,
|
id: huId,
|
||||||
initiative_id: projectId,
|
initiative_id: projectId,
|
||||||
@@ -193,7 +225,7 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
story_points: hu.story_points ?? null,
|
story_points: hu.story_points ?? null,
|
||||||
estimated_hours: null,
|
estimated_hours: null,
|
||||||
actual_hours: null,
|
actual_hours: null,
|
||||||
assigned_to: null,
|
assigned_to: assignedUserId,
|
||||||
sprint: safeStr(hu.sprint),
|
sprint: safeStr(hu.sprint),
|
||||||
has_impairment: hasImpairment,
|
has_impairment: hasImpairment,
|
||||||
item_type: null,
|
item_type: null,
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export interface KappaUserStory {
|
|||||||
story_points?: number
|
story_points?: number
|
||||||
sprint?: string
|
sprint?: string
|
||||||
created_at?: string
|
created_at?: string
|
||||||
|
assigned_to?: number | null
|
||||||
|
asignado_a?: number[] | string[] | null
|
||||||
|
assigned_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KappaEpicDevelopment {
|
export interface KappaEpicDevelopment {
|
||||||
|
|||||||
+179
-11
@@ -3,6 +3,8 @@ import { computed, watch, ref } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import { useWorkItemsStore } from '@/stores/workitems'
|
import { useWorkItemsStore } from '@/stores/workitems'
|
||||||
|
import { useUsersStore } from '@/stores/users'
|
||||||
|
import { storage } from '@/services/storage'
|
||||||
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
|
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 { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus, Brain, Sparkles, Loader2, CheckCircle2, XCircle, Send, ChevronDown } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { generateAndSavePlan, getQAPlans, type HUQAPlan } from '@/services/qa-analyzer'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -26,9 +35,125 @@ import {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const projects = useProjectsStore()
|
const projects = useProjectsStore()
|
||||||
const workItems = useWorkItemsStore()
|
const workItems = useWorkItemsStore()
|
||||||
|
const usersStore = useUsersStore()
|
||||||
|
|
||||||
const project = computed(() => projects.selected)
|
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<{
|
const emit = defineEmits<{
|
||||||
'navigate-settings': []
|
'navigate-settings': []
|
||||||
}>()
|
}>()
|
||||||
@@ -226,6 +351,9 @@ watch(
|
|||||||
async (id) => {
|
async (id) => {
|
||||||
if (id) {
|
if (id) {
|
||||||
await workItems.fetchWorkItems(id)
|
await workItems.fetchWorkItems(id)
|
||||||
|
if (usersStore.users.length === 0) {
|
||||||
|
usersStore.fetchAll()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -506,29 +634,65 @@ const statusLabel = (status: unknown) => {
|
|||||||
<Card id="dashboard-hus-table">
|
<Card id="dashboard-hus-table">
|
||||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle class="text-sm font-medium">{{ t('dashboard.userStoriesTitle') }}</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent class="space-y-3">
|
||||||
<Table v-if="workItems.userStories.length > 0">
|
<!-- 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>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead class="w-[80px]">{{ t('dashboard.code') }}</TableHead>
|
<TableHead class="w-[80px]">{{ t('dashboard.code') }}</TableHead>
|
||||||
<TableHead class="w-[60px]">{{ t('users.role') }}</TableHead>
|
<TableHead class="w-[60px]">{{ t('users.role') }}</TableHead>
|
||||||
<TableHead>{{ t('dashboard.title') }}</TableHead>
|
<TableHead>{{ t('dashboard.title') }}</TableHead>
|
||||||
<TableHead class="w-[110px]">{{ t('dashboard.status') }}</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<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 class="font-mono text-xs text-muted-foreground">{{ hu.code || `HU-${hu.id}` }}</TableCell>
|
||||||
<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>
|
<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>
|
||||||
<TableCell class="text-sm max-w-[280px] truncate flex items-center gap-1">
|
<TableCell class="text-sm flex items-center gap-1 py-3">
|
||||||
<AlertTriangle v-if="hu.has_impairment" class="size-3.5 text-amber-500 flex-shrink-0" title="Tiene impedimentos pendientes" />
|
<AlertTriangle v-if="hu.has_impairment" class="size-3.5 text-amber-500 shrink-0" title="Tiene impedimentos pendientes" />
|
||||||
<span class="truncate">{{ hu._cleanTitle || hu.title }}</span>
|
<span>{{ hu._cleanTitle || hu.title }}</span>
|
||||||
<span v-if="hu._criteriaList?.length" class="group relative inline-flex flex-shrink-0 cursor-help">
|
<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" />
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell><Badge :variant="statusVariant(hu.status || '')" class="text-xs capitalize">{{ statusLabel(hu.status || '') }}</Badge></TableCell>
|
<TableCell><Badge :variant="statusVariant(hu.status || '')" class="text-xs capitalize">{{ hu._statusName || statusLabel(hu.status || '') }}</Badge></TableCell>
|
||||||
<TableCell class="text-right text-xs text-muted-foreground">{{ hu.priority || '—' }}</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>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
Reference in New Issue
Block a user