diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 26eec86..0a6aea2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -111,7 +111,10 @@ "status": "Status", "priority": "Priority", "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": { "active": "Active", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2cfd630..e0aa4fc 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -111,7 +111,10 @@ "status": "Estado", "priority": "Prioridad", "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": { "active": "Activo", diff --git a/src/main.ts b/src/main.ts index 9432736..9f78637 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,8 +5,12 @@ import { i18n } from './i18n' import 'ag-grid-community/styles/ag-grid.css' import './assets/ag-grid-alpha.css' import './style.css' +import db from '@/services/db' const app = createApp(App) app.use(createPinia()) app.use(i18n) app.mount('#app') + +// Debug: exponer BD para consultas desde consola del navegador +;(window as any).__db = db diff --git a/src/services/db.ts b/src/services/db.ts index 02da8f5..5a64659 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -111,6 +111,22 @@ export interface CellMemberRecord { 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 & { settings: Dexie.Table project_docs: Dexie.Table @@ -123,9 +139,10 @@ const db = new Dexie('alpha-core') as Dexie & { lookups: Dexie.Table cells: Dexie.Table cell_members: Dexie.Table + user_stories: Dexie.Table } -db.version(7).stores({ +db.version(8).stores({ settings: '&key', project_docs: '&projectId, projectName, updatedAt', sessions: '++id, projectId, date', @@ -137,6 +154,7 @@ db.version(7).stores({ lookups: '&[type+id], type', cells: '&id', cell_members: '[cellId+userId], cellId, userId', + user_stories: '&id, initiative_id', }) export default db diff --git a/src/services/statuses-db.ts b/src/services/statuses-db.ts new file mode 100644 index 0000000..8dda985 --- /dev/null +++ b/src/services/statuses-db.ts @@ -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 = { + '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 | 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 { + 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 { + 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 +} diff --git a/src/services/tauri-db.ts b/src/services/tauri-db.ts index afdb162..b89ea51 100644 --- a/src/services/tauri-db.ts +++ b/src/services/tauri-db.ts @@ -43,6 +43,19 @@ async function fallbackInvoke(cmd: string, args?: Record): P if (u) try { await (db as any).table('alpha_users').put(u) } catch {} 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 ───────────────── default: return null as unknown as T diff --git a/src/stores/workitems.ts b/src/stores/workitems.ts index b981d2b..e8cafaa 100644 --- a/src/stores/workitems.ts +++ b/src/stores/workitems.ts @@ -6,6 +6,7 @@ import { tauriDb, type UserStoryRecord, type EpicRecord, type ImpairmentRecord } import { stripHtml } from '@/services/clean-html' import { criteriaToJson, parseQuillList } from '@/services/clean-html' 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' export interface EnrichedUserStory extends KappaUserStory { @@ -14,6 +15,9 @@ export interface EnrichedUserStory extends KappaUserStory { _cleanTitle: string _criteriaList: string[] has_impairment: boolean + _assignedUserId: number | null + _assignedName: string + _statusName: string } 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 rawCriteria = hu.acceptance_criteria || hu.criterios_aceptacion || '' const criteriaList = rawCriteria ? parseQuillList(rawCriteria) : [] + const { id: assignedUserId, name: assignedName } = parseAssignedUser(hu) return { id: hu.id ?? 0, title: hu.title || '', @@ -74,6 +97,9 @@ export const useWorkItemsStore = defineStore('workitems', () => { _cleanTitle: h ? stripHierarchy(hu.title || '') : (hu.title || ''), _criteriaList: criteriaList, 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) + if (isFirstVisit) { + seedStatusLookups().catch(() => {}) + } try { // 1. Cargar desde Turso (instantáneo) @@ -116,6 +145,7 @@ export const useWorkItemsStore = defineStore('workitems', () => { epics.value = localEpics.map(e => enrichEpic(e, id)) userStories.value = localHUs.map(hu => enrichHU(hu, id)) + } // 2. Consultar KAPPA (siempre, para detectar cambios) @@ -180,6 +210,8 @@ export const useWorkItemsStore = defineStore('workitems', () => { hasImpairment = impairments.some(p => !p.status) } catch {} + const { id: assignedUserId } = parseAssignedUser(hu) + await tauriDb.saveUserStory({ id: huId, initiative_id: projectId, @@ -193,7 +225,7 @@ export const useWorkItemsStore = defineStore('workitems', () => { story_points: hu.story_points ?? null, estimated_hours: null, actual_hours: null, - assigned_to: null, + assigned_to: assignedUserId, sprint: safeStr(hu.sprint), has_impairment: hasImpairment, item_type: null, diff --git a/src/types/kappa.ts b/src/types/kappa.ts index 200cc6f..ca80b86 100644 --- a/src/types/kappa.ts +++ b/src/types/kappa.ts @@ -49,6 +49,9 @@ export interface KappaUserStory { story_points?: number sprint?: string created_at?: string + assigned_to?: number | null + asignado_a?: number[] | string[] | null + assigned_name?: string } export interface KappaEpicDevelopment { diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index bb62638..e7a0ec2 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -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(`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() + 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 { + return storage.getJSON>(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) => { {{ t('dashboard.userStoriesTitle') }} - {{ t('dashboard.husCount', { count: workItems.userStories.length }) }} + {{ t('dashboard.husCount', { count: filteredHUs.length }) }} / {{ workItems.userStories.length }} - - + + +
+ + + +
+ +
{{ t('dashboard.code') }} {{ t('users.role') }} {{ t('dashboard.title') }} {{ t('dashboard.status') }} - {{ t('dashboard.priority') }} + {{ t('dashboard.priority') }} + {{ t('dashboard.assignedTo') }} - + {{ hu.code || `HU-${hu.id}` }} {{ getTypeLabel(hu._itemType) }} - - - {{ hu._cleanTitle || hu.title }} - + + + {{ hu._cleanTitle || hu.title }} +

Criterios de aceptación

@@ -536,8 +700,12 @@ const statusLabel = (status: unknown) => {
- {{ statusLabel(hu.status || '') }} - {{ hu.priority || '—' }} + {{ hu._statusName || statusLabel(hu.status || '') }} + {{ priorityLabel(hu.priority) }} + + {{ assignedName(hu) }} + {{ t('dashboard.unassigned') }} +