1f39c4df7a
- services/report-export.ts: generación de DOCX con docx (npm) incluye: estado general, épicas, tabla HUs, bloqueos, curva S, sesiones - DashboardView: botón Exportar al lado del badge del proyecto stats simplificados a 4 tarjetas (combinada épicas/HUs/progreso, QA, sesiones, curva S) - SCurveChart: modo compacto con área gradient fill estilo shadcn - Notificación inline: soporte para tipo 'info' (azul)
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
import { kappa } from '@/services/kappa-api'
|
|
import { storage } from '@/services/storage'
|
|
import { tauriDb, type UserStoryRecord, type EpicRecord, type ImpairmentRecord } from '@/services/tauri-db'
|
|
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 {
|
|
_itemType: ItemType
|
|
_hierarchyPath: string | null
|
|
_cleanTitle: string
|
|
_criteriaList: string[]
|
|
has_impairment: boolean
|
|
_assignedUserId: number | null
|
|
_assignedName: string
|
|
_statusName: string
|
|
_assignedEmployeeId: number | null
|
|
_epicCode: string | null
|
|
_itemCode: string | null
|
|
}
|
|
|
|
export interface EnrichedEpic extends KappaEpicDevelopment {
|
|
_itemType: ItemType
|
|
_hierarchyPath: string | null
|
|
_cleanName: string
|
|
_epicCode: string | null
|
|
}
|
|
|
|
export const useWorkItemsStore = defineStore('workitems', () => {
|
|
const creating = ref(false)
|
|
const loading = ref(false)
|
|
const syncing = ref(false)
|
|
const error = ref<string | null>(null)
|
|
const firstVisit = ref<Set<number>>(new Set())
|
|
|
|
const userStories = ref<EnrichedUserStory[]>([])
|
|
const epics = ref<EnrichedEpic[]>([])
|
|
const logbooks = ref<KappaLogbookEntry[]>([])
|
|
const plannings = ref<KappaPlanningEntry[]>([])
|
|
|
|
const totalHUs = computed(() => userStories.value.length)
|
|
const totalEpics = computed(() => epics.value.length)
|
|
const inProgressHUs = computed(() =>
|
|
userStories.value.filter(hu => {
|
|
const s = String(hu.status ?? '').toLowerCase()
|
|
return ['in_progress', 'doing', 'wip', 'active', 'in progress', 'true'].includes(s)
|
|
}).length
|
|
)
|
|
const totalSessions = computed(() => logbooks.value.length)
|
|
|
|
function enrichEpic(e: { name?: string; title?: string; id?: number }, initiativeId: number): EnrichedEpic {
|
|
const title = e.name || e.title || ''
|
|
const h = parseHierarchy(title)
|
|
return {
|
|
id: e.id ?? 0,
|
|
code: undefined,
|
|
name: e.name,
|
|
title: e.name || e.title,
|
|
initiative: initiativeId,
|
|
_itemType: h?.items[h.items.length - 1]?.type || 'E',
|
|
_hierarchyPath: h?.fullPath ?? null,
|
|
_cleanName: h ? stripHierarchy(title) : title,
|
|
_epicCode: h?.epicCode ?? null,
|
|
}
|
|
}
|
|
|
|
function parseAssignedUser(hu: any): { id: number | null; name: string; employeeId: number | null } {
|
|
// 1. asignado_a → EMPLOYEE IDs (ej: [1135]). NO son user_ids.
|
|
if (hu.asignado_a != null) {
|
|
if (Array.isArray(hu.asignado_a)) {
|
|
const first = hu.asignado_a[0]
|
|
if (first != null) {
|
|
const employeeId = typeof first === 'object' ? Number(first.id) : Number(first)
|
|
if (!isNaN(employeeId)) {
|
|
const name = Array.isArray(hu.asignado_a_names) ? (hu.asignado_a_names[0] ?? '') : (hu.asignado_a_names ?? '')
|
|
return { id: null, name, employeeId }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 2. assigned_to con valor negativo → employee_id (convención Turso)
|
|
if (hu.assigned_to != null && hu.assigned_to !== '') {
|
|
const raw = Number(hu.assigned_to)
|
|
if (!isNaN(raw)) {
|
|
if (raw < 0) return { id: null, name: '', employeeId: Math.abs(raw) }
|
|
return { id: raw, name: hu.assigned_name || '', employeeId: null }
|
|
}
|
|
}
|
|
return { id: null, name: '', employeeId: null }
|
|
}
|
|
|
|
function enrichHU(hu: { title?: string; id?: number; description?: string | null; status?: string | number | null; status_name?: string | null; priority?: string | number | null; story_points?: number | null; end_date?: string | null; sprint?: number | string | null; acceptance_criteria?: string | null; criterios_aceptacion?: string | null; assigned_to?: number | null; asignado_a?: number[] | string[] | null; asignado_a_names?: string[] | 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, employeeId } = parseAssignedUser(hu)
|
|
return {
|
|
id: hu.id ?? 0,
|
|
title: hu.title || '',
|
|
status: String(hu.status ?? ''),
|
|
priority: String(hu.priority ?? ''),
|
|
story_points: hu.story_points ?? undefined,
|
|
end_date: hu.end_date || undefined,
|
|
sprint: hu.sprint != null ? String(hu.sprint) : undefined,
|
|
description: hu.description || '',
|
|
acceptance_criteria: rawCriteria,
|
|
criterios_aceptacion: rawCriteria,
|
|
initiative: initiativeId,
|
|
_itemType: h?.items[h.items.length - 1]?.type || 'U',
|
|
_hierarchyPath: h?.fullPath ?? null,
|
|
_cleanTitle: h ? stripHierarchy(hu.title || '') : (hu.title || ''),
|
|
_criteriaList: criteriaList,
|
|
has_impairment: false,
|
|
_assignedUserId: assignedUserId,
|
|
_assignedName: assignedName,
|
|
_statusName: hu.status_name || resolveStatusName(hu.status),
|
|
_assignedEmployeeId: employeeId,
|
|
_epicCode: h?.epicCode ?? null,
|
|
_itemCode: h?.itemCode ?? null,
|
|
}
|
|
}
|
|
|
|
async function createUserStory(story: KappaUserStory): Promise<EnrichedUserStory | null> {
|
|
creating.value = true
|
|
error.value = null
|
|
try {
|
|
const result = await kappa.createUserStory(story)
|
|
const enriched = enrichHU(result, Number(result.initiative))
|
|
userStories.value.push(enriched)
|
|
return enriched
|
|
} catch (e: any) {
|
|
error.value = e.message
|
|
return null
|
|
} finally {
|
|
creating.value = false
|
|
}
|
|
}
|
|
|
|
async function fetchWorkItems(initiativeId?: number) {
|
|
loading.value = true
|
|
error.value = null
|
|
|
|
const id = initiativeId || Number(storage.get('kappa_last_project'))
|
|
if (!id) {
|
|
loading.value = false
|
|
return
|
|
}
|
|
|
|
const isFirstVisit = !firstVisit.value.has(id)
|
|
if (isFirstVisit) {
|
|
seedStatusLookups().catch(() => {})
|
|
}
|
|
|
|
try {
|
|
// 1. Cargar desde Turso (instantáneo)
|
|
if (!isFirstVisit) {
|
|
const [localEpics, localHUs] = await Promise.all([
|
|
tauriDb.getEpics(id).catch(() => [] as EpicRecord[]),
|
|
tauriDb.getUserStories(id).catch(() => [] as UserStoryRecord[]),
|
|
])
|
|
|
|
epics.value = localEpics.map(e => enrichEpic(e, id))
|
|
|
|
userStories.value = localHUs.map(hu => enrichHU(hu, id))
|
|
|
|
}
|
|
|
|
// 2. Consultar KAPPA (siempre, para detectar cambios)
|
|
syncing.value = true
|
|
const [stories, epicData] = await Promise.all([
|
|
kappa.getAllUserStories(id).catch(() => [] as KappaUserStory[]),
|
|
kappa.getAllEpicDevelopment(id).catch(() => [] as KappaEpicDevelopment[]),
|
|
])
|
|
|
|
// 3. Merge: detectar nuevos/cambiados
|
|
const localHUIds = new Set(userStories.value.map(hu => hu.id))
|
|
const newHUs = stories.filter(s => !localHUIds.has(s.id))
|
|
|
|
const localEpicIds = new Set(epics.value.map(e => e.id))
|
|
const newEpics = epicData.filter(e => !localEpicIds.has(e.id))
|
|
|
|
// 4. Actualizar UI con datos frescos de KAPPA
|
|
userStories.value = stories.map(hu => enrichHU(hu, id))
|
|
|
|
epics.value = epicData.map(epic => ({
|
|
...epic,
|
|
description: stripHtml(epic.description || ''),
|
|
...enrichEpic(epic, id),
|
|
}))
|
|
|
|
// 5. Guardar en Turso: insertar/actualizar HUs y épicas
|
|
if (isFirstVisit || newHUs.length > 0) {
|
|
await syncHUsToTurso(id, isFirstVisit ? stories : newHUs)
|
|
}
|
|
if (isFirstVisit || newEpics.length > 0) {
|
|
await syncEpicsToTurso(id, isFirstVisit ? epicData : newEpics)
|
|
}
|
|
// Actualizar contadores en projects
|
|
await tauriDb.updateProjectCounts(id).catch(() => {})
|
|
|
|
firstVisit.value.add(id)
|
|
} catch (e: any) {
|
|
error.value = e.message
|
|
} finally {
|
|
loading.value = false
|
|
syncing.value = false
|
|
}
|
|
}
|
|
|
|
function safeStr(v: unknown): string | null {
|
|
if (v === null || v === undefined) return null
|
|
return String(v)
|
|
}
|
|
|
|
async function syncHUsToTurso(projectId: number, hus: KappaUserStory[]) {
|
|
console.log(`[Alpha] Syncing ${hus.length} HUs to Turso for project ${projectId}`)
|
|
for (const hu of hus) {
|
|
try {
|
|
const huId = Number(hu.id) || 0
|
|
|
|
// Consultar impedimentos en KAPPA
|
|
let hasImpairment = false
|
|
let impairments: KappaPending[] = []
|
|
try {
|
|
const pendingResp = await kappa.getPendings(huId)
|
|
impairments = pendingResp.results
|
|
hasImpairment = impairments.some(p => !p.status)
|
|
} catch {}
|
|
|
|
const { id: assignedUserId, employeeId } = parseAssignedUser(hu)
|
|
// Convención: employee_id se guarda como negativo para distinguirlo de user_id
|
|
const assignedTo = employeeId != null ? -employeeId : assignedUserId
|
|
|
|
await tauriDb.saveUserStory({
|
|
id: huId,
|
|
initiative_id: projectId,
|
|
epic_id: null,
|
|
code: safeStr(hu.code),
|
|
title: String(hu.title || ''),
|
|
description: stripHtml(String(hu.description || '')),
|
|
acceptance_criteria: (hu.acceptance_criteria || hu.criterios_aceptacion || null) as string | null,
|
|
status: safeStr(hu.status),
|
|
priority: safeStr(hu.priority),
|
|
story_points: hu.story_points ?? null,
|
|
estimated_hours: null,
|
|
actual_hours: null,
|
|
assigned_to: assignedTo,
|
|
sprint: safeStr(hu.sprint),
|
|
has_impairment: hasImpairment,
|
|
item_type: null,
|
|
hierarchy_path: null,
|
|
parent_code: null,
|
|
created_at: null,
|
|
})
|
|
|
|
// Guardar impedimentos en Turso
|
|
for (const p of impairments) {
|
|
await tauriDb.saveImpairment({
|
|
id: p.id,
|
|
hu_id: huId,
|
|
responsible: p.responsible || null,
|
|
pending_activity: p.pending_activity || null,
|
|
pending_type: p.type || null,
|
|
type_impediment: p.type_impediment,
|
|
delivery_date: p.delivery_date || null,
|
|
status: p.status,
|
|
created_at: p.created_at || null,
|
|
updated_at: p.updated_at || null,
|
|
}).catch(() => {})
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Alpha] Failed to save HU ${hu.id}:`, e)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function syncEpicsToTurso(projectId: number, epicsData: KappaEpicDevelopment[]) {
|
|
console.log(`[Alpha] Syncing ${epicsData.length} epics to Turso for project ${projectId}`)
|
|
for (const epic of epicsData) {
|
|
try {
|
|
await tauriDb.saveEpic({
|
|
id: Number(epic.id) || 0,
|
|
initiative_id: projectId,
|
|
code: safeStr(epic.code),
|
|
name: String(epic.name || epic.title || `Épica ${epic.id}`),
|
|
description: safeStr(epic.description),
|
|
status: safeStr(epic.status),
|
|
client_taker: null,
|
|
stimated_start_date: null,
|
|
stimated_end_date: null,
|
|
start_date: null,
|
|
end_date: null,
|
|
created_at: null,
|
|
updated_at: null,
|
|
})
|
|
} catch (e) {
|
|
console.error(`[Alpha] Failed to save epic ${epic.id}:`, e)
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
creating,
|
|
loading,
|
|
syncing,
|
|
error,
|
|
userStories,
|
|
epics,
|
|
logbooks,
|
|
plannings,
|
|
totalHUs,
|
|
totalEpics,
|
|
inProgressHUs,
|
|
totalSessions,
|
|
createUserStory,
|
|
fetchWorkItems,
|
|
}
|
|
})
|