hierarchy: parsear nomenclatura [E05-F04-U02], badges de tipo en dashboard, clean title
This commit is contained in:
@@ -128,6 +128,8 @@ pub struct Epic {
|
|||||||
pub end_date: Option<String>,
|
pub end_date: Option<String>,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
pub updated_at: Option<String>,
|
pub updated_at: Option<String>,
|
||||||
|
pub item_type: Option<String>,
|
||||||
|
pub hierarchy_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
@@ -146,6 +148,9 @@ pub struct UserStory {
|
|||||||
pub actual_hours: Option<f64>,
|
pub actual_hours: Option<f64>,
|
||||||
pub assigned_to: Option<i64>,
|
pub assigned_to: Option<i64>,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
|
pub item_type: Option<String>,
|
||||||
|
pub hierarchy_path: Option<String>,
|
||||||
|
pub parent_code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_conn(db_path: &str) -> Result<libsql::Connection, String> {
|
async fn get_conn(db_path: &str) -> Result<libsql::Connection, String> {
|
||||||
@@ -278,6 +283,8 @@ async fn get_conn(db_path: &str) -> Result<libsql::Connection, String> {
|
|||||||
stimated_end_date TEXT,
|
stimated_end_date TEXT,
|
||||||
start_date TEXT,
|
start_date TEXT,
|
||||||
end_date TEXT,
|
end_date TEXT,
|
||||||
|
item_type TEXT DEFAULT 'E',
|
||||||
|
hierarchy_path TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now')),
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (initiative_id) REFERENCES projects(id)
|
FOREIGN KEY (initiative_id) REFERENCES projects(id)
|
||||||
@@ -297,6 +304,9 @@ async fn get_conn(db_path: &str) -> Result<libsql::Connection, String> {
|
|||||||
estimated_hours REAL,
|
estimated_hours REAL,
|
||||||
actual_hours REAL,
|
actual_hours REAL,
|
||||||
assigned_to INTEGER,
|
assigned_to INTEGER,
|
||||||
|
item_type TEXT DEFAULT 'U',
|
||||||
|
hierarchy_path TEXT,
|
||||||
|
parent_code TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (initiative_id) REFERENCES projects(id),
|
FOREIGN KEY (initiative_id) REFERENCES projects(id),
|
||||||
FOREIGN KEY (epic_id) REFERENCES epics(id)
|
FOREIGN KEY (epic_id) REFERENCES epics(id)
|
||||||
|
|||||||
@@ -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 { kappa } from '@/services/kappa-api'
|
||||||
import { tauriDb, type UserStoryRecord, type EpicRecord } from '@/services/tauri-db'
|
import { tauriDb, type UserStoryRecord, type EpicRecord } from '@/services/tauri-db'
|
||||||
import { stripHtml } from '@/services/clean-html'
|
import { stripHtml } from '@/services/clean-html'
|
||||||
|
import { parseHierarchy, stripHierarchy, getItemType, type ItemType } from '@/services/hierarchy'
|
||||||
import type { KappaUserStory, KappaLogbookEntry, KappaPlanningEntry, KappaEpicDevelopment } from '@/types/kappa'
|
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', () => {
|
export const useWorkItemsStore = defineStore('workitems', () => {
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -12,8 +25,8 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const firstVisit = ref<Set<number>>(new Set())
|
const firstVisit = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
const userStories = ref<KappaUserStory[]>([])
|
const userStories = ref<EnrichedUserStory[]>([])
|
||||||
const epics = ref<KappaEpicDevelopment[]>([])
|
const epics = ref<EnrichedEpic[]>([])
|
||||||
const logbooks = ref<KappaLogbookEntry[]>([])
|
const logbooks = ref<KappaLogbookEntry[]>([])
|
||||||
const plannings = ref<KappaPlanningEntry[]>([])
|
const plannings = ref<KappaPlanningEntry[]>([])
|
||||||
|
|
||||||
@@ -27,13 +40,41 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
)
|
)
|
||||||
const totalSessions = computed(() => logbooks.value.length)
|
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
|
creating.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const result = await kappa.createUserStory(story)
|
const result = await kappa.createUserStory(story)
|
||||||
userStories.value.push(result)
|
const enriched = enrichHU(result, Number(result.initiative))
|
||||||
return result
|
userStories.value.push(enriched)
|
||||||
|
return enriched
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
return null
|
return null
|
||||||
@@ -62,27 +103,9 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
tauriDb.getUserStories(id).catch(() => [] as UserStoryRecord[]),
|
tauriDb.getUserStories(id).catch(() => [] as UserStoryRecord[]),
|
||||||
])
|
])
|
||||||
|
|
||||||
epics.value = localEpics.map(e => ({
|
epics.value = localEpics.map(e => enrichEpic(e, id))
|
||||||
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,
|
|
||||||
}))
|
|
||||||
|
|
||||||
userStories.value = localHUs.map(hu => ({
|
userStories.value = localHUs.map(hu => enrichHU(hu, id))
|
||||||
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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Consultar KAPPA (siempre, para detectar cambios)
|
// 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))
|
const newEpics = epicData.filter(e => !localEpicIds.has(e.id))
|
||||||
|
|
||||||
// 4. Actualizar UI con datos frescos de KAPPA
|
// 4. Actualizar UI con datos frescos de KAPPA
|
||||||
userStories.value = stories
|
userStories.value = stories.map(hu => enrichHU(hu, id))
|
||||||
|
|
||||||
epics.value = epicData.map(epic => ({
|
epics.value = epicData.map(epic => ({
|
||||||
...epic,
|
...epic,
|
||||||
description: stripHtml(epic.description || ''),
|
description: stripHtml(epic.description || ''),
|
||||||
|
...enrichEpic(epic, id),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 5. Guardar en Turso: insertar/actualizar HUs y épicas
|
// 5. Guardar en Turso: insertar/actualizar HUs y épicas
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, watch } 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 { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
|
||||||
import { Activity, FileText, Layers, Clock } from 'lucide-vue-next'
|
import { Activity, FileText, Layers, Clock } from 'lucide-vue-next'
|
||||||
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'
|
||||||
@@ -152,10 +153,16 @@ const statusLabel = (status: unknown) => {
|
|||||||
<CardHeader class="p-4 pb-2">
|
<CardHeader class="p-4 pb-2">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<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">
|
<span class="font-mono text-xs text-muted-foreground flex-shrink-0">
|
||||||
{{ epic.code || `EP-${epic.id}` }}
|
{{ epic.code || `EP-${epic.id}` }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<Badge :variant="statusVariant(epic.status || '')" class="text-xs flex-shrink-0">
|
<Badge :variant="statusVariant(epic.status || '')" class="text-xs flex-shrink-0">
|
||||||
{{ statusLabel(epic.status || '') }}
|
{{ statusLabel(epic.status || '') }}
|
||||||
@@ -182,7 +189,8 @@ const statusLabel = (status: unknown) => {
|
|||||||
<Table v-if="workItems.userStories.length > 0">
|
<Table v-if="workItems.userStories.length > 0">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<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>{{ 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] text-right">{{ t('dashboard.priority') }}</TableHead>
|
||||||
@@ -193,8 +201,16 @@ const statusLabel = (status: unknown) => {
|
|||||||
<TableCell class="font-mono text-xs text-muted-foreground">
|
<TableCell class="font-mono text-xs text-muted-foreground">
|
||||||
{{ hu.code || `HU-${hu.id}` }}
|
{{ hu.code || `HU-${hu.id}` }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-sm max-w-[300px] truncate">
|
<TableCell>
|
||||||
{{ hu.title }}
|
<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>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge :variant="statusVariant(hu.status || '')" class="text-xs capitalize">
|
<Badge :variant="statusVariant(hu.status || '')" class="text-xs capitalize">
|
||||||
|
|||||||
Reference in New Issue
Block a user