hierarchy: parsear nomenclatura [E05-F04-U02], badges de tipo en dashboard, clean title
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
export type ItemType = 'E' | 'F' | 'U' | 'T' | 'B'
|
||||
|
||||
export interface HierarchyInfo {
|
||||
fullPath: string
|
||||
items: { type: ItemType; number: number }[]
|
||||
epicCode: string | null
|
||||
featureCode: string | null
|
||||
itemCode: string | null
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<ItemType, string> = {
|
||||
E: 'Épica',
|
||||
F: 'Feature',
|
||||
U: 'HU',
|
||||
T: 'Tarea',
|
||||
B: 'Bug',
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<ItemType, string> = {
|
||||
E: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
F: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
U: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
|
||||
T: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
B: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400',
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<ItemType, string> = {
|
||||
E: '📦',
|
||||
F: '⭐',
|
||||
U: '📋',
|
||||
T: '⚙️',
|
||||
B: '🐛',
|
||||
}
|
||||
|
||||
export function getTypeLabel(type: ItemType): string {
|
||||
return TYPE_LABELS[type] || type
|
||||
}
|
||||
|
||||
export function getTypeColor(type: ItemType): string {
|
||||
return TYPE_COLORS[type] || 'bg-muted text-muted-foreground'
|
||||
}
|
||||
|
||||
export function getTypeIcon(type: ItemType): string {
|
||||
return TYPE_ICONS[type] || '📄'
|
||||
}
|
||||
|
||||
const HIERARCHY_REGEX = /\[(([EFUTB]\d+)(?:-([EFUTB]\d+))?(?:-([EFUTB]\d+))?)\]/i
|
||||
|
||||
export function parseHierarchy(title: string): HierarchyInfo | null {
|
||||
const match = title.match(HIERARCHY_REGEX)
|
||||
if (!match) return null
|
||||
|
||||
const fullPath = match[1].toUpperCase()
|
||||
const segments = [match[2]]
|
||||
if (match[3]) segments.push(match[3])
|
||||
if (match[4]) segments.push(match[4])
|
||||
|
||||
const items = segments.map(s => ({
|
||||
type: s[0] as ItemType,
|
||||
number: parseInt(s.slice(1), 10),
|
||||
}))
|
||||
|
||||
const epic = items.find(i => i.type === 'E')
|
||||
const feature = items.find(i => i.type === 'F')
|
||||
const item = items[items.length - 1]
|
||||
|
||||
return {
|
||||
fullPath,
|
||||
items,
|
||||
epicCode: epic ? `E${String(epic.number).padStart(2, '0')}` : null,
|
||||
featureCode: feature ? `F${String(feature.number).padStart(2, '0')}` : null,
|
||||
itemCode: item ? `${item.type}${String(item.number).padStart(2, '0')}` : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function stripHierarchy(title: string): string {
|
||||
return title.replace(HIERARCHY_REGEX, '').trim()
|
||||
}
|
||||
|
||||
export function getItemType(title: string): ItemType {
|
||||
const h = parseHierarchy(title)
|
||||
if (h?.items.length) return h.items[h.items.length - 1].type
|
||||
return 'U'
|
||||
}
|
||||
|
||||
export function buildHierarchyPath(
|
||||
epicNum?: number,
|
||||
featureNum?: number,
|
||||
itemType?: ItemType,
|
||||
itemNum?: number,
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
if (epicNum !== undefined) parts.push(`E${String(epicNum).padStart(2, '0')}`)
|
||||
if (featureNum !== undefined) parts.push(`F${String(featureNum).padStart(2, '0')}`)
|
||||
if (itemType && itemNum !== undefined) parts.push(`${itemType}${String(itemNum).padStart(2, '0')}`)
|
||||
return parts.length ? `[${parts.join('-')}]` : ''
|
||||
}
|
||||
|
||||
export function buildFullTitle(
|
||||
name: string,
|
||||
epicNum?: number,
|
||||
featureNum?: number,
|
||||
itemType?: ItemType,
|
||||
itemNum?: number,
|
||||
): string {
|
||||
const path = buildHierarchyPath(epicNum, featureNum, itemType, itemNum)
|
||||
return path ? `${path} ${name}` : name
|
||||
}
|
||||
|
||||
export function extractItemNumber(title: string, type: ItemType): number | null {
|
||||
const h = parseHierarchy(title)
|
||||
if (!h) return null
|
||||
const found = h.items.find(i => i.type === type)
|
||||
return found?.number ?? null
|
||||
}
|
||||
+50
-26
@@ -3,8 +3,21 @@ import { ref, computed } from 'vue'
|
||||
import { kappa } from '@/services/kappa-api'
|
||||
import { tauriDb, type UserStoryRecord, type EpicRecord } from '@/services/tauri-db'
|
||||
import { stripHtml } from '@/services/clean-html'
|
||||
import { parseHierarchy, stripHierarchy, getItemType, type ItemType } from '@/services/hierarchy'
|
||||
import type { KappaUserStory, KappaLogbookEntry, KappaPlanningEntry, KappaEpicDevelopment } from '@/types/kappa'
|
||||
|
||||
export interface EnrichedUserStory extends KappaUserStory {
|
||||
_itemType: ItemType
|
||||
_hierarchyPath: string | null
|
||||
_cleanTitle: string
|
||||
}
|
||||
|
||||
export interface EnrichedEpic extends KappaEpicDevelopment {
|
||||
_itemType: ItemType
|
||||
_hierarchyPath: string | null
|
||||
_cleanName: string
|
||||
}
|
||||
|
||||
export const useWorkItemsStore = defineStore('workitems', () => {
|
||||
const creating = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -12,8 +25,8 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
||||
const error = ref<string | null>(null)
|
||||
const firstVisit = ref<Set<number>>(new Set())
|
||||
|
||||
const userStories = ref<KappaUserStory[]>([])
|
||||
const epics = ref<KappaEpicDevelopment[]>([])
|
||||
const userStories = ref<EnrichedUserStory[]>([])
|
||||
const epics = ref<EnrichedEpic[]>([])
|
||||
const logbooks = ref<KappaLogbookEntry[]>([])
|
||||
const plannings = ref<KappaPlanningEntry[]>([])
|
||||
|
||||
@@ -27,13 +40,41 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
||||
)
|
||||
const totalSessions = computed(() => logbooks.value.length)
|
||||
|
||||
async function createUserStory(story: KappaUserStory): Promise<KappaUserStory | null> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function enrichHU(hu: { title?: string; id?: number }, initiativeId: number): EnrichedUserStory {
|
||||
const h = parseHierarchy(hu.title || '')
|
||||
return {
|
||||
id: hu.id ?? 0,
|
||||
title: hu.title || '',
|
||||
initiative: initiativeId,
|
||||
_itemType: h?.items[h.items.length - 1]?.type || 'U',
|
||||
_hierarchyPath: h?.fullPath ?? null,
|
||||
_cleanTitle: h ? stripHierarchy(hu.title || '') : (hu.title || ''),
|
||||
}
|
||||
}
|
||||
|
||||
async function createUserStory(story: KappaUserStory): Promise<EnrichedUserStory | null> {
|
||||
creating.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await kappa.createUserStory(story)
|
||||
userStories.value.push(result)
|
||||
return result
|
||||
const enriched = enrichHU(result, Number(result.initiative))
|
||||
userStories.value.push(enriched)
|
||||
return enriched
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
return null
|
||||
@@ -62,27 +103,9 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
||||
tauriDb.getUserStories(id).catch(() => [] as UserStoryRecord[]),
|
||||
])
|
||||
|
||||
epics.value = localEpics.map(e => ({
|
||||
id: e.id,
|
||||
code: e.code || undefined,
|
||||
name: e.name,
|
||||
title: e.name,
|
||||
description: e.description || undefined,
|
||||
status: e.status || undefined,
|
||||
client_taker: e.client_taker || undefined,
|
||||
initiative: id,
|
||||
}))
|
||||
epics.value = localEpics.map(e => enrichEpic(e, id))
|
||||
|
||||
userStories.value = localHUs.map(hu => ({
|
||||
id: hu.id,
|
||||
code: hu.code || undefined,
|
||||
title: hu.title,
|
||||
description: hu.description || undefined,
|
||||
acceptance_criteria: hu.acceptance_criteria || undefined,
|
||||
status: hu.status || undefined,
|
||||
priority: hu.priority || undefined,
|
||||
initiative: id,
|
||||
}))
|
||||
userStories.value = localHUs.map(hu => enrichHU(hu, id))
|
||||
}
|
||||
|
||||
// 2. Consultar KAPPA (siempre, para detectar cambios)
|
||||
@@ -100,11 +123,12 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
||||
const newEpics = epicData.filter(e => !localEpicIds.has(e.id))
|
||||
|
||||
// 4. Actualizar UI con datos frescos de KAPPA
|
||||
userStories.value = stories
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useProjectsStore } from '@/stores/projects'
|
||||
import { useWorkItemsStore } from '@/stores/workitems'
|
||||
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
|
||||
import { Activity, FileText, Layers, Clock } from 'lucide-vue-next'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -152,10 +153,16 @@ const statusLabel = (status: unknown) => {
|
||||
<CardHeader class="p-4 pb-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold"
|
||||
:class="getTypeColor(epic._itemType)"
|
||||
>
|
||||
{{ getTypeIcon(epic._itemType) }} {{ getTypeLabel(epic._itemType) }}
|
||||
</span>
|
||||
<span class="font-mono text-xs text-muted-foreground flex-shrink-0">
|
||||
{{ epic.code || `EP-${epic.id}` }}
|
||||
</span>
|
||||
<CardTitle class="text-sm truncate"> {{ epic.name || epic.title || t('dashboard.epicFallback', { id: epic.id }) }}</CardTitle>
|
||||
<CardTitle class="text-sm truncate">{{ epic._cleanName || epic.name || epic.title || t('dashboard.epicFallback', { id: epic.id }) }}</CardTitle>
|
||||
</div>
|
||||
<Badge :variant="statusVariant(epic.status || '')" class="text-xs flex-shrink-0">
|
||||
{{ statusLabel(epic.status || '') }}
|
||||
@@ -182,7 +189,8 @@ const statusLabel = (status: unknown) => {
|
||||
<Table v-if="workItems.userStories.length > 0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[100px]">{{ t('dashboard.code') }}</TableHead>
|
||||
<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>
|
||||
@@ -193,8 +201,16 @@ const statusLabel = (status: unknown) => {
|
||||
<TableCell class="font-mono text-xs text-muted-foreground">
|
||||
{{ hu.code || `HU-${hu.id}` }}
|
||||
</TableCell>
|
||||
<TableCell class="text-sm max-w-[300px] truncate">
|
||||
{{ hu.title }}
|
||||
<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">
|
||||
{{ hu._cleanTitle || hu.title }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="statusVariant(hu.status || '')" class="text-xs capitalize">
|
||||
|
||||
Reference in New Issue
Block a user